diff --git a/.github/workflows/test-unit-proxy-db.yml b/.github/workflows/test-unit-proxy-db.yml index d5781f767f..5a9688db9c 100644 --- a/.github/workflows/test-unit-proxy-db.yml +++ b/.github/workflows/test-unit-proxy-db.yml @@ -141,6 +141,7 @@ jobs: tests/proxy_unit_tests/test_server_root_path.py tests/proxy_unit_tests/test_proxy_pass_user_config.py tests/proxy_unit_tests/test_proxy_token_counter.py + tests/proxy_unit_tests/test_request_size_limit_middleware.py workers: 4 dist: loadscope timeout: 15 diff --git a/.gitignore b/.gitignore index 59812ed6ed..20355a8e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ STABILIZATION_TODO.md **/playwright-report **/*.storageState.json **/coverage -test-config \ No newline at end of file +test-config +.vscode \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4bdbf26ae9..e99bf79d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -241,10 +241,27 @@ When opening issues or pull requests, follow these templates: ### Running the proxy server -Start the proxy with a config file: +Create a minimal config file and start the proxy: + +```yaml +# config.yaml +model_list: + - model_name: fake-openai-endpoint + litellm_params: + model: openai/fake-model + api_key: fake-key + api_base: https://fake-api.example.com + +general_settings: + master_key: sk-1234 + +litellm_settings: + drop_params: True + telemetry: False +``` ```bash -uv run litellm --config dev_config.yaml --port 4000 +uv run litellm --config config.yaml --port 4000 ``` The proxy takes ~15-20 seconds to fully start (it runs Prisma migrations on boot). Wait for `/health` to return before sending requests. Without a PostgreSQL `DATABASE_URL`, the proxy connects to a default Neon dev database embedded in the `litellm-proxy-extras` package. diff --git a/CLAUDE.md b/CLAUDE.md index 71e5af28ee..938801df7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components: - **Bound large result sets.** Prisma materializes full results in memory. For results over ~10 MB, paginate with `take`/`skip` or `cursor`/`take`, always with an explicit `order`. Prefer cursor-based pagination (`skip` is O(n)). Don't paginate naturally small result sets. - **Limit fetched columns on wide tables.** Use `select` to fetch only needed fields — returns a partial object, so downstream code must not access unselected fields. - **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries. -- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`. +- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`. ### Setup Wizard (`litellm/setup_wizard.py`) - The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI). diff --git a/Dockerfile b/Dockerfile index 915daff999..9ad9ab31b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31 +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9 # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:3258be472764337fd13095bcbb3182da170243b5819fd67ad4c0754590588b31 +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:31da6565f35af6401031c1d7aa91dc84ac76c5c48edd17fb90f0ed9e3173c7a9 ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a FROM $UV_IMAGE AS uvbin @@ -68,8 +68,8 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root -RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile supervisor && \ - npm install -g npm@11.12.1 tar@7.5.11 glob@13.0.6 @isaacs/brace-expansion@5.0.1 brace-expansion@5.0.5 minimatch@10.2.4 diff@8.0.3 picomatch@4.0.4 && \ +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile && \ + npm install -g npm@11.14.0 tar@7.5.11 glob@13.0.6 @isaacs/brace-expansion@5.0.1 brace-expansion@5.0.5 minimatch@10.2.4 diff@8.0.3 picomatch@4.0.4 && \ GLOBAL="$(npm root -g)" && \ for pkg in tar glob @isaacs/brace-expansion brace-expansion minimatch diff picomatch; do \ name="${pkg##*/}"; \ @@ -85,17 +85,17 @@ ENV PATH="/app/.venv/bin:${PATH}" COPY --from=builder /app /app # Prisma binaries live in $HOME/.cache (default prisma-python location), -# which is /root/.cache here. Copy them from the builder so they survive -# deployments that volume-mount /app/.cache (e.g. readOnlyRootFilesystem -# + emptyDir) — otherwise the mount would shadow the baked-in query engine. -COPY --from=builder /root/.cache /root/.cache +# which is /root/.cache here. Copy only the Prisma subdirs — copying the +# whole /root/.cache drags in the uv build cache (~660 MB, includes a +# setuptools wheel that surfaces as a CVE finding even though it's not +# on the runtime sys.path). +COPY --from=builder /root/.cache/prisma /root/.cache/prisma +COPY --from=builder /root/.cache/prisma-python /root/.cache/prisma-python RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ find /app/.venv -type d -path "*/tornado/test" -delete EXPOSE 4000/tcp -COPY docker/supervisord.conf /etc/supervisord.conf - ENTRYPOINT ["docker/prod_entrypoint.sh"] CMD ["--port", "4000"] diff --git a/codecov.yaml b/codecov.yaml index 09fccc6b99..8609d3143d 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,3 +1,8 @@ +codecov: + require_ci_to_pass: false # post coverage status even if CI has unrelated failures + notify: + wait_for_ci: false # post as soon as expected uploads arrive, don't wait on CI + component_management: individual_components: - component_id: "Router" @@ -28,7 +33,7 @@ coverage: project: default: target: auto - threshold: 1% # at maximum allow project coverage to drop by 1% + threshold: 0% # do not allow project coverage to drop patch: default: target: auto diff --git a/deploy/Dockerfile.ghcr_base b/deploy/Dockerfile.ghcr_base deleted file mode 100644 index 66e64e5b77..0000000000 --- a/deploy/Dockerfile.ghcr_base +++ /dev/null @@ -1,18 +0,0 @@ -# Use the provided base image -FROM ghcr.io/berriai/litellm:main-latest@sha256:7c311546c25e7bb6e8cafede9fcd3d0d622ac636b5c9418befaa32e85dfb0186 - -# Set the working directory to /app -WORKDIR /app - -# Copy the configuration file into the container at /app -COPY config.yaml . - -# Make sure your docker/entrypoint.sh is executable -# Convert Windows line endings to Unix -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh - -# Expose the necessary port -EXPOSE 4000/tcp - -# Override the CMD instruction with your desired command and arguments -CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug", "--run_gunicorn"] diff --git a/deploy/charts/litellm-helm/templates/deployment.yaml b/deploy/charts/litellm-helm/templates/deployment.yaml index a9b41e0d8e..25f6908087 100644 --- a/deploy/charts/litellm-helm/templates/deployment.yaml +++ b/deploy/charts/litellm-helm/templates/deployment.yaml @@ -100,6 +100,16 @@ spec: - name: DATABASE_URL value: {{ .Values.db.url | quote }} {{- end }} + {{- if and .Values.db.useExisting .Values.db.secret.readReplicaUrlKey }} + - name: DATABASE_URL_READ_REPLICA + valueFrom: + secretKeyRef: + name: {{ .Values.db.secret.name }} + key: {{ .Values.db.secret.readReplicaUrlKey }} + {{- else if .Values.db.readReplicaUrl }} + - name: DATABASE_URL_READ_REPLICA + value: {{ .Values.db.readReplicaUrl | quote }} + {{- end }} - name: PROXY_MASTER_KEY valueFrom: secretKeyRef: @@ -129,12 +139,6 @@ spec: value: {{ $val | quote }} {{- end }} {{- end }} - {{- if .Values.separateHealthApp }} - - name: SEPARATE_HEALTH_APP - value: "1" - - name: SEPARATE_HEALTH_PORT - value: {{ .Values.separateHealthPort | default "8081" | quote }} - {{- end }} {{- with .Values.extraEnvVars }} {{- toYaml . | nindent 12 }} {{- end }} @@ -175,15 +179,10 @@ spec: - name: http containerPort: {{ .Values.service.port }} protocol: TCP - {{- if .Values.separateHealthApp }} - - name: health - containerPort: {{ .Values.separateHealthPort | default 8081 }} - protocol: TCP - {{- end }} livenessProbe: httpGet: path: {{ .Values.livenessProbe.path | quote }} - port: {{ if .Values.separateHealthApp }}"health"{{ else }}"http"{{ end }} + port: "http" initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.livenessProbe.periodSeconds }} timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }} @@ -192,7 +191,7 @@ spec: readinessProbe: httpGet: path: {{ .Values.readinessProbe.path | quote }} - port: {{ if .Values.separateHealthApp }}"health"{{ else }}"http"{{ end }} + port: "http" initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.readinessProbe.periodSeconds }} timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }} @@ -201,7 +200,7 @@ spec: startupProbe: httpGet: path: {{ .Values.startupProbe.path | quote }} - port: {{ if .Values.separateHealthApp }}"health"{{ else }}"http"{{ end }} + port: "http" initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }} periodSeconds: {{ .Values.startupProbe.periodSeconds }} timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }} diff --git a/deploy/charts/litellm-helm/values.yaml b/deploy/charts/litellm-helm/values.yaml index 3dd1112bea..9c7c013341 100644 --- a/deploy/charts/litellm-helm/values.yaml +++ b/deploy/charts/litellm-helm/values.yaml @@ -88,12 +88,6 @@ service: # optionally specify loadBalancerClass # loadBalancerClass: tailscale -# Separate health app configuration -# When enabled, health checks will use a separate port and the application -# will receive SEPARATE_HEALTH_APP=1 and SEPARATE_HEALTH_PORT from environment variables -separateHealthApp: false -separateHealthPort: 8081 - # Probes for LiteLLM gateway container livenessProbe: path: /health/liveliness @@ -258,6 +252,26 @@ db: passwordKey: password # Optional: when set, DATABASE_HOST will be sourced from this secret key instead of db.endpoint endpointKey: "" + # Optional: when set, DATABASE_URL_READ_REPLICA will be sourced from this + # secret key instead of db.readReplicaUrl. Prefer this over the plain + # value: read-replica URLs typically embed credentials, and a value + # written to db.readReplicaUrl ends up visible in the rendered pod spec + # and the Helm release secret. + readReplicaUrlKey: "" + + # Optional read-replica routing. When set, the proxy sends read-only + # queries (find_*, count, group_by, query_raw/_first) to this URL while + # writes continue to go to db.url. Useful for Aurora-style clusters with + # separate reader/writer endpoints. Leave empty to keep single-DB behavior. + # When IAM_TOKEN_DB_AUTH is enabled, the reader URL is auto-refreshed + # alongside the writer (host/port/user/db are parsed from this URL once + # at startup; only the IAM token rotates). + # + # If the URL embeds credentials, prefer db.secret.readReplicaUrlKey over + # this field — the plain value is rendered into the pod spec and the + # Helm release secret. This field is intended for credential-less URLs + # only (e.g. when IAM_TOKEN_DB_AUTH supplies the token at runtime). + readReplicaUrl: "" # Use the Stackgres Helm chart to deploy an instance of a Stackgres cluster. # The Stackgres Operator must already be installed within the target diff --git a/deploy/kubernetes/kub.yaml b/deploy/kubernetes/kub.yaml deleted file mode 100644 index d5ba500d8f..0000000000 --- a/deploy/kubernetes/kub.yaml +++ /dev/null @@ -1,56 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: litellm-deployment -spec: - replicas: 3 - selector: - matchLabels: - app: litellm - template: - metadata: - labels: - app: litellm - spec: - containers: - - name: litellm-container - image: ghcr.io/berriai/litellm:main-latest - imagePullPolicy: Always - env: - - name: AZURE_API_KEY - value: "d6f****" - - name: AZURE_API_BASE - value: "https://openai" - - name: LITELLM_MASTER_KEY - value: "sk-1234" - - name: DATABASE_URL - value: "postgresql://ishaan*********" - args: - - "--config" - - "/app/proxy_config.yaml" # Update the path to mount the config file - volumeMounts: # Define volume mount for proxy_config.yaml - - name: config-volume - mountPath: /app - readOnly: true - livenessProbe: - httpGet: - path: /health/liveliness - port: 4000 - initialDelaySeconds: 120 - periodSeconds: 15 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 10 - readinessProbe: - httpGet: - path: /health/readiness - port: 4000 - initialDelaySeconds: 120 - periodSeconds: 15 - successThreshold: 1 - failureThreshold: 3 - timeoutSeconds: 10 - volumes: # Define volume to mount proxy_config.yaml - - name: config-volume - configMap: - name: litellm-config diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml deleted file mode 100644 index 4751c83725..0000000000 --- a/deploy/kubernetes/service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: litellm-service -spec: - selector: - app: litellm - ports: - - protocol: TCP - port: 4000 - targetPort: 4000 - type: LoadBalancer \ No newline at end of file diff --git a/dev_config.yaml b/dev_config.yaml deleted file mode 100644 index 64e3c14703..0000000000 --- a/dev_config.yaml +++ /dev/null @@ -1,13 +0,0 @@ -model_list: - - model_name: fake-openai-endpoint - litellm_params: - model: openai/fake-model - api_key: fake-key - api_base: https://exampleopenaiendpoint-production.up.railway.app/ - -general_settings: - master_key: sk-1234 - -litellm_settings: - drop_params: True - telemetry: False diff --git a/docker-compose.yml b/docker-compose.yml index 988860a787..80e1f289aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,11 @@ services: - "4000:4000" # Map the container port to the host, change the host port if necessary environment: DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" + # Optional: route read-only queries (find_*, count, group_by, query_raw/_first) + # to a separate reader endpoint, e.g. an Aurora reader. Leave unset for + # single-DB deployments. With IAM_TOKEN_DB_AUTH enabled, the reader URL + # is auto-refreshed alongside the writer. + # DATABASE_URL_READ_REPLICA: "postgresql://llmproxy:dbpassword9090@db-reader:5432/litellm" STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI env_file: - .env # Load local .env file diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine deleted file mode 100644 index 5de588cf4e..0000000000 --- a/docker/Dockerfile.alpine +++ /dev/null @@ -1,68 +0,0 @@ -# Base image for building -ARG LITELLM_BUILD_IMAGE=python:3.11-alpine@sha256:f07e2ace46f560f09a6eeec7b4913b80ee99546e749ef82342a419a326620856 - -# Runtime image -ARG LITELLM_RUNTIME_IMAGE=python:3.11-alpine@sha256:f07e2ace46f560f09a6eeec7b4913b80ee99546e749ef82342a419a326620856 -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apk add --no-cache gcc python3-dev musl-dev nodejs npm libsndfile - -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python3 - -# Copy full source tree -COPY . . - -# Install project and workspace packages (fast - deps already cached) -RUN uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python3 - -RUN 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 - -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -RUN apk upgrade --no-cache && apk add --no-cache libsndfile nodejs npm - -WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -COPY --from=builder /app /app - -EXPOSE 4000/tcp - -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/Dockerfile.custom_ui b/docker/Dockerfile.custom_ui deleted file mode 100644 index cc44893bf9..0000000000 --- a/docker/Dockerfile.custom_ui +++ /dev/null @@ -1,86 +0,0 @@ -# Use the provided base image -# NOTE: This is a dev/branch-specific tag. Update digest when the base image is rebuilt. -FROM ghcr.io/berriai/litellm:litellm_fwd_server_root_path-dev - -# Set the working directory to /app -WORKDIR /app - -# Install Node.js and npm (adjust version as needed) -RUN apt-get update && apt-get upgrade -y \ - libxml2 \ - libexpat1 \ - openssl \ - libssl3 \ - git \ - libkrb5-3 \ - libglib2.0-0 \ - wget \ - libaom3 \ - libxslt1.1 \ - libgnutls30 \ - libc6 && \ - apt-get install -y --no-install-recommends nodejs npm && \ - npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 && \ - GLOBAL="$(npm root -g)" && \ - find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/tar" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "glob" -path "*/node_modules/glob" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/glob" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "brace-expansion" -path "*/node_modules/@isaacs/brace-expansion" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/@isaacs/brace-expansion" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "minimatch" -path "*/node_modules/minimatch" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/minimatch" "$d"; \ - done && \ - find "$GLOBAL/npm" -type d -name "diff" -path "*/node_modules/diff" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/diff" "$d"; \ - done && \ - find /usr/local/lib /usr/lib -path "*/node_modules/npm/package.json" -exec \ - sed -i 's/"tar": "\^7\.5\.[0-9]*"/"tar": "^7.5.10"/g; s/"minimatch": "\^10\.[0-9.]*"/"minimatch": "^10.2.4"/g' {} + 2>/dev/null && \ - npm cache clean --force && \ - apt-get purge -y npm - -# Copy the UI source into the container -COPY ./ui/litellm-dashboard /app/ui/litellm-dashboard - -# Set an environment variable for UI_BASE_PATH -# This can be overridden at build time -# set UI_BASE_PATH to "/ui" -ENV UI_BASE_PATH="/prod/ui" - -# Build the UI with the specified UI_BASE_PATH -WORKDIR /app/ui/litellm-dashboard -RUN npm ci -RUN UI_BASE_PATH=$UI_BASE_PATH npm run build - -# Create the destination directory -RUN mkdir -p /app/litellm/proxy/_experimental/out - -# Move the built files to the appropriate location -# Assuming the build output is in ./out directory -RUN rm -rf /app/litellm/proxy/_experimental/out/* && \ - mv ./out/* /app/litellm/proxy/_experimental/out/ - -# Switch back to the main app directory -WORKDIR /app - -# Make sure your docker/entrypoint.sh is executable -# Convert Windows line endings to Unix for entrypoint scripts -RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh -RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh - -# Run as non-root user -RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser \ - && chown -R appuser:appuser /app -USER appuser - -# Expose the necessary port -EXPOSE 4000/tcp - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"] - -# Override the CMD instruction with your desired command and arguments -CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug"] \ No newline at end of file diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 671f374ca2..c84003a065 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -66,7 +66,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root -RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile supervisor && \ +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 libsndfile && \ npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 && \ GLOBAL="$(npm root -g)" && \ find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ @@ -102,7 +102,5 @@ RUN find /app/.venv -type f -path "*/tornado/test/*" -delete && \ EXPOSE 4000/tcp -COPY docker/supervisord.conf /etc/supervisord.conf - ENTRYPOINT ["docker/prod_entrypoint.sh"] CMD ["--port", "4000"] diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev deleted file mode 100644 index ebc92a22d5..0000000000 --- a/docker/Dockerfile.dev +++ /dev/null @@ -1,121 +0,0 @@ -# Base image for building -ARG LITELLM_BUILD_IMAGE=python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d - -# Runtime image -ARG LITELLM_RUNTIME_IMAGE=python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a - -FROM $UV_IMAGE AS uvbin - -FROM $LITELLM_BUILD_IMAGE AS builder - -WORKDIR /app -USER root - -COPY --from=uvbin /uv /usr/local/bin/uv -COPY --from=uvbin /uvx /usr/local/bin/uvx - -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - g++ \ - python3-dev \ - libssl-dev \ - pkg-config \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* - -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - UV_LINK_MODE=copy \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -# Copy dependency metadata first for layer caching -COPY pyproject.toml uv.lock ./ -COPY enterprise/pyproject.toml enterprise/ -COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/ - -# Install third-party dependencies (cached unless pyproject.toml/uv.lock change) -RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python - -# Copy full source tree -COPY . . - -# Build Admin UI before final sync -RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.sh && ./docker/build_admin_ui.sh - -# Install project and workspace packages (fast - deps already cached) -RUN uv sync --frozen --no-default-groups --no-editable \ - --extra proxy \ - --extra proxy-runtime \ - --extra extra_proxy \ - --extra semantic-router \ - --python python - -RUN 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 - -FROM $LITELLM_RUNTIME_IMAGE AS runtime - -USER root - -RUN apt-get update && apt-get upgrade -y \ - libxml2 \ - libexpat1 \ - openssl \ - libssl3 \ - git \ - libkrb5-3 \ - libglib2.0-0 \ - wget \ - libaom3 \ - libxslt1.1 \ - libgnutls30 \ - libc6 \ - && apt-get install -y --no-install-recommends \ - libssl3 \ - libatomic1 \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g npm@11.12.1 tar@7.5.11 glob@11.1.0 @isaacs/brace-expansion@5.0.1 minimatch@10.2.4 diff@8.0.3 \ - && GLOBAL="$(npm root -g)" \ - && find "$GLOBAL/npm" -type d -name "tar" -path "*/node_modules/tar" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/tar" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "glob" -path "*/node_modules/glob" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/glob" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "brace-expansion" -path "*/node_modules/@isaacs/brace-expansion" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/@isaacs/brace-expansion" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "minimatch" -path "*/node_modules/minimatch" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/minimatch" "$d"; \ - done \ - && find "$GLOBAL/npm" -type d -name "diff" -path "*/node_modules/diff" | while read d; do \ - rm -rf "$d" && cp -rL "$GLOBAL/diff" "$d"; \ - done \ - && find /usr/local/lib /usr/lib -path "*/node_modules/npm/package.json" -exec \ - sed -i 's/"tar": "\^7\.5\.[0-9]*"/"tar": "^7.5.10"/g; s/"minimatch": "\^10\.[0-9.]*"/"minimatch": "^10.2.4"/g' {} + 2>/dev/null \ - && npm cache clean --force \ - && apt-get purge -y npm - -WORKDIR /app -ENV PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ - XDG_CACHE_HOME=/app/.cache \ - PATH="/app/.venv/bin:${PATH}" - -COPY --from=builder /app /app - -EXPOSE 4000/tcp - -ENTRYPOINT ["docker/prod_entrypoint.sh"] -CMD ["--port", "4000"] diff --git a/docker/Dockerfile.health_check b/docker/Dockerfile.health_check deleted file mode 100644 index a2e5cb9f71..0000000000 --- a/docker/Dockerfile.health_check +++ /dev/null @@ -1,30 +0,0 @@ -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a -FROM $UV_IMAGE AS uvbin - -FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d - -WORKDIR /app - -# Copy the uv binary and the health check script. -COPY --from=uvbin /uv /usr/local/bin/uv -COPY pyproject.toml uv.lock /app/ -COPY scripts/health_check/health_check_client.py /app/health_check_client.py - -# Resolve and install the health-check dependencies from the project lockfile -# so the runtime image stays self-contained and reproducible. -RUN uv export --frozen --no-default-groups --only-group healthcheck --no-emit-project --no-hashes --output-file /tmp/health-check-requirements.txt \ - && uv pip install --system -r /tmp/health-check-requirements.txt \ - && rm /tmp/health-check-requirements.txt \ - && rm /app/pyproject.toml /app/uv.lock \ - && chmod +x /app/health_check_client.py - -# Run as non-root user -RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser -USER appuser - -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD ["python", "/app/health_check_client.py", "--help"] - -# Set entrypoint -ENTRYPOINT ["python", "/app/health_check_client.py"] diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 3fa73f4243..4de4a55981 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -103,13 +103,12 @@ RUN for i in 1 2 3; do \ apk upgrade --no-cache && break || sleep 5; \ done && \ for i in 1 2 3; do \ - apk add --no-cache python3 bash openssl tzdata supervisor libsndfile nodejs && break || sleep 5; \ + apk add --no-cache python3 bash openssl tzdata libsndfile nodejs && break || sleep 5; \ done COPY --from=builder /app /app COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets -COPY --from=builder /app/docker/supervisord.conf /etc/supervisord.conf ENV PATH="/app/.venv/bin:${PATH}" \ PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \ diff --git a/docker/prod_entrypoint.sh b/docker/prod_entrypoint.sh index 28d1bdcc29..bd78bf6687 100644 --- a/docker/prod_entrypoint.sh +++ b/docker/prod_entrypoint.sh @@ -1,14 +1,8 @@ #!/bin/sh -if [ "$SEPARATE_HEALTH_APP" = "1" ]; then - export LITELLM_ARGS="$@" - export SUPERVISORD_STOPWAITSECS="${SUPERVISORD_STOPWAITSECS:-3600}" - exec supervisord -c /etc/supervisord.conf -fi - if [ "$USE_DDTRACE" = "true" ]; then export DD_TRACE_OPENAI_ENABLED="False" exec ddtrace-run litellm "$@" else exec litellm "$@" -fi \ No newline at end of file +fi diff --git a/docker/supervisord.conf b/docker/supervisord.conf deleted file mode 100644 index ba9d99d18a..0000000000 --- a/docker/supervisord.conf +++ /dev/null @@ -1,46 +0,0 @@ -[supervisord] -nodaemon=true -loglevel=info -logfile=/tmp/supervisord.log -pidfile=/tmp/supervisord.pid - -[group:litellm] -programs=main,health - -[program:main] -command=sh -c 'if [ "$USE_DDTRACE" = "true" ]; then export DD_TRACE_OPENAI_ENABLED="False"; exec ddtrace-run python -m litellm.proxy.proxy_cli --host 0.0.0.0 --port=4000 $LITELLM_ARGS; else exec python -m litellm.proxy.proxy_cli --host 0.0.0.0 --port=4000 $LITELLM_ARGS; fi' -autostart=true -autorestart=true -startretries=3 -priority=1 -exitcodes=0 -stopasgroup=true -killasgroup=true -stopwaitsecs=%(ENV_SUPERVISORD_STOPWAITSECS)s -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr -stdout_logfile_maxbytes = 0 -stderr_logfile_maxbytes = 0 -environment=PYTHONUNBUFFERED=true - -[program:health] -command=sh -c '[ "$SEPARATE_HEALTH_APP" = "1" ] && exec uvicorn litellm.proxy.health_endpoints.health_app_factory:build_health_app --factory --host 0.0.0.0 --port=${SEPARATE_HEALTH_PORT:-4001} || exit 0' -autostart=true -autorestart=true -startretries=3 -priority=2 -exitcodes=0 -stopasgroup=true -killasgroup=true -stopwaitsecs=%(ENV_SUPERVISORD_STOPWAITSECS)s -stdout_logfile=/dev/stdout -stderr_logfile=/dev/stderr -stdout_logfile_maxbytes = 0 -stderr_logfile_maxbytes = 0 -environment=PYTHONUNBUFFERED=true - -[eventlistener:process_monitor] -command=python -c "from supervisor import childutils; import os, signal; [os.kill(os.getppid(), signal.SIGTERM) for h,p in iter(lambda: childutils.listener.wait(), None) if h['eventname'] in ['PROCESS_STATE_FATAL', 'PROCESS_STATE_EXITED'] and dict([x.split(':') for x in p.split(' ')])['processname'] in ['main', 'health'] or childutils.listener.ok()]" -events=PROCESS_STATE_EXITED,PROCESS_STATE_FATAL -autostart=true -autorestart=true \ No newline at end of file diff --git a/index.yaml b/index.yaml deleted file mode 100644 index 9b2461c36b..0000000000 --- a/index.yaml +++ /dev/null @@ -1,108 +0,0 @@ -apiVersion: v1 -entries: - litellm-helm: - - apiVersion: v2 - appVersion: v1.43.18 - created: "2024-08-19T23:58:25.331689+08:00" - dependencies: - - condition: db.deployStandalone - name: postgresql - repository: oci://registry-1.docker.io/bitnamicharts - version: '>=13.3.0' - - condition: redis.enabled - name: redis - repository: oci://registry-1.docker.io/bitnamicharts - version: '>=18.0.0' - description: Call all LLM APIs using the OpenAI format - digest: 0411df3dc42868be8af3ad3e00cb252790e6bd7ad15f5b77f1ca5214573a8531 - name: litellm-helm - type: application - urls: - - https://berriai.github.io/litellm/litellm-helm-0.2.3.tgz - version: 0.2.3 - postgresql: - - annotations: - category: Database - images: | - - name: os-shell - image: docker.io/bitnami/os-shell:12-debian-12-r16 - - name: postgres-exporter - image: docker.io/bitnami/postgres-exporter:0.15.0-debian-12-r14 - - name: postgresql - image: docker.io/bitnami/postgresql:16.2.0-debian-12-r6 - licenses: Apache-2.0 - apiVersion: v2 - appVersion: 16.2.0 - created: "2024-08-19T23:58:25.335716+08:00" - dependencies: - - name: common - repository: oci://registry-1.docker.io/bitnamicharts - tags: - - bitnami-common - version: 2.x.x - description: PostgreSQL (Postgres) is an open source object-relational database - known for reliability and data integrity. ACID-compliant, it supports foreign - keys, joins, views, triggers and stored procedures. - digest: 3c8125526b06833df32e2f626db34aeaedb29d38f03d15349db6604027d4a167 - home: https://bitnami.com - icon: https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-220x234.png - keywords: - - postgresql - - postgres - - database - - sql - - replication - - cluster - maintainers: - - name: VMware, Inc. - url: https://github.com/bitnami/charts - name: postgresql - sources: - - https://github.com/bitnami/charts/tree/main/bitnami/postgresql - urls: - - https://berriai.github.io/litellm/charts/postgresql-14.3.1.tgz - version: 14.3.1 - redis: - - annotations: - category: Database - images: | - - name: kubectl - image: docker.io/bitnami/kubectl:1.29.2-debian-12-r3 - - name: os-shell - image: docker.io/bitnami/os-shell:12-debian-12-r16 - - name: redis - image: docker.io/bitnami/redis:7.2.4-debian-12-r9 - - name: redis-exporter - image: docker.io/bitnami/redis-exporter:1.58.0-debian-12-r4 - - name: redis-sentinel - image: docker.io/bitnami/redis-sentinel:7.2.4-debian-12-r7 - licenses: Apache-2.0 - apiVersion: v2 - appVersion: 7.2.4 - created: "2024-08-19T23:58:25.339392+08:00" - dependencies: - - name: common - repository: oci://registry-1.docker.io/bitnamicharts - tags: - - bitnami-common - version: 2.x.x - description: Redis(R) is an open source, advanced key-value store. It is often - referred to as a data structure server since keys can contain strings, hashes, - lists, sets and sorted sets. - digest: b2fa1835f673a18002ca864c54fadac3c33789b26f6c5e58e2851b0b14a8f984 - home: https://bitnami.com - icon: https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png - keywords: - - redis - - keyvalue - - database - maintainers: - - name: VMware, Inc. - url: https://github.com/bitnami/charts - name: redis - sources: - - https://github.com/bitnami/charts/tree/main/bitnami/redis - urls: - - https://berriai.github.io/litellm/charts/redis-18.19.1.tgz - version: 18.19.1 -generated: "2024-08-19T23:58:25.322532+08:00" diff --git a/litellm-js/proxy/.npmrc b/litellm-js/proxy/.npmrc deleted file mode 100644 index 7999681cc3..0000000000 --- a/litellm-js/proxy/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Supply-chain hardening -# Packages needing lifecycle scripts: npm rebuild -ignore-scripts=true -# Protects local npm install only — npm ci (used in CI) ignores this -min-release-age=3 diff --git a/litellm-js/proxy/README.md b/litellm-js/proxy/README.md deleted file mode 100644 index cc58e962d8..0000000000 --- a/litellm-js/proxy/README.md +++ /dev/null @@ -1,8 +0,0 @@ -``` -npm install -npm run dev -``` - -``` -npm run deploy -``` diff --git a/litellm-js/proxy/package-lock.json b/litellm-js/proxy/package-lock.json deleted file mode 100644 index 0d09fa1a6c..0000000000 --- a/litellm-js/proxy/package-lock.json +++ /dev/null @@ -1,2054 +0,0 @@ -{ - "name": "proxy", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "hono": "4.12.16", - "openai": "4.29.2" - }, - "devDependencies": { - "@cloudflare/workers-types": "4.20260501.1", - "wrangler": "4.87.0" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", - "integrity": "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", - "integrity": "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", - "integrity": "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", - "integrity": "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", - "integrity": "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260501.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", - "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/base-64": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", - "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/digest-fetch": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz", - "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==", - "license": "ISC", - "dependencies": { - "base-64": "^0.1.0", - "md5": "^2.3.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formdata-node/node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "license": "MIT" - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/miniflare": { - "version": "4.20260430.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260430.0.tgz", - "integrity": "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260430.1", - "ws": "8.18.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openai": { - "version": "4.29.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.29.2.tgz", - "integrity": "sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "digest-fetch": "^1.3.0", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7", - "web-streams-polyfill": "^3.2.1" - }, - "bin": { - "openai": "bin/cli" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/workerd": { - "version": "1.20260430.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260430.1.tgz", - "integrity": "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260430.1", - "@cloudflare/workerd-darwin-arm64": "1.20260430.1", - "@cloudflare/workerd-linux-64": "1.20260430.1", - "@cloudflare/workerd-linux-arm64": "1.20260430.1", - "@cloudflare/workerd-windows-64": "1.20260430.1" - } - }, - "node_modules/wrangler": { - "version": "4.87.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.87.0.tgz", - "integrity": "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260430.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260430.1" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260430.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - } - } -} diff --git a/litellm-js/proxy/package.json b/litellm-js/proxy/package.json deleted file mode 100644 index 9fd94cd882..0000000000 --- a/litellm-js/proxy/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "scripts": { - "dev": "wrangler dev src/index.ts", - "deploy": "wrangler deploy --minify src/index.ts" - }, - "dependencies": { - "hono": "4.12.16", - "openai": "4.29.2" - }, - "devDependencies": { - "@cloudflare/workers-types": "4.20260501.1", - "wrangler": "4.87.0" - } -} diff --git a/litellm-js/proxy/src/index.ts b/litellm-js/proxy/src/index.ts deleted file mode 100644 index dc5dc9c689..0000000000 --- a/litellm-js/proxy/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Hono } from 'hono' -import { Context } from 'hono'; -import { bearerAuth } from 'hono/bearer-auth' -import OpenAI from "openai"; - -const openai = new OpenAI({ - apiKey: "sk-1234", - baseURL: "https://openai-endpoint.ishaanjaffer0324.workers.dev" -}); - -async function call_proxy() { - const completion = await openai.chat.completions.create({ - messages: [{ role: "system", content: "You are a helpful assistant." }], - model: "gpt-3.5-turbo", - }); - - return completion -} - -const app = new Hono() - -// Middleware for API Key Authentication -const apiKeyAuth = async (c: Context, next: Function) => { - const apiKey = c.req.header('Authorization'); - if (!apiKey || apiKey !== 'Bearer sk-1234') { - return c.text('Unauthorized', 401); - } - await next(); -}; - - -app.use('/*', apiKeyAuth) - - -app.get('/', (c) => { - return c.text('Hello Hono!') -}) - - - - -// Handler for chat completions -const chatCompletionHandler = async (c: Context) => { - // Assuming your logic for handling chat completion goes here - // For demonstration, just returning a simple JSON response - const response = await call_proxy() - return c.json(response); -}; - -// Register the above handler for different POST routes with the apiKeyAuth middleware -app.post('/v1/chat/completions', chatCompletionHandler); -app.post('/chat/completions', chatCompletionHandler); - -// Example showing how you might handle dynamic segments within the URL -// Here, using ':model*' to capture the rest of the path as a parameter 'model' -app.post('/openai/deployments/:model*/chat/completions', chatCompletionHandler); - - -export default app diff --git a/litellm-js/proxy/tsconfig.json b/litellm-js/proxy/tsconfig.json deleted file mode 100644 index 28fcfb5824..0000000000 --- a/litellm-js/proxy/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "lib": [ - "ESNext" - ], - "types": [ - "@cloudflare/workers-types" - ], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - "skipLibCheck": true - }, -} \ No newline at end of file diff --git a/litellm-js/proxy/wrangler.toml b/litellm-js/proxy/wrangler.toml deleted file mode 100644 index e7c323dff9..0000000000 --- a/litellm-js/proxy/wrangler.toml +++ /dev/null @@ -1,18 +0,0 @@ -name = "my-app" -compatibility_date = "2023-12-01" - -# [vars] -# MY_VAR = "my-variable" - -# [[kv_namespaces]] -# binding = "MY_KV_NAMESPACE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# [[r2_buckets]] -# binding = "MY_BUCKET" -# bucket_name = "my-bucket" - -# [[d1_databases]] -# binding = "DB" -# database_name = "my-database" -# database_id = "" diff --git a/litellm-js/spend-logs/.npmrc b/litellm-js/spend-logs/.npmrc deleted file mode 100644 index 7999681cc3..0000000000 --- a/litellm-js/spend-logs/.npmrc +++ /dev/null @@ -1,5 +0,0 @@ -# Supply-chain hardening -# Packages needing lifecycle scripts: npm rebuild -ignore-scripts=true -# Protects local npm install only — npm ci (used in CI) ignores this -min-release-age=3 diff --git a/litellm-js/spend-logs/Dockerfile b/litellm-js/spend-logs/Dockerfile deleted file mode 100644 index 5040dc74bf..0000000000 --- a/litellm-js/spend-logs/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Use the specific Node.js v20.11.0 image -FROM node:20.18.1-alpine3.20 - -# Set the working directory inside the container -WORKDIR /app - -# Copy package.json and package-lock.json to the working directory -COPY ./litellm-js/spend-logs/package*.json ./ - -# Install dependencies -RUN npm ci - -# Install Prisma globally -RUN npm install -g prisma - -# Copy the rest of the application code -COPY ./litellm-js/spend-logs . - -# Generate Prisma client -RUN npx prisma generate - -# Expose the port that the Node.js server will run on -EXPOSE 3000 - -# Command to run the Node.js app with npm run dev -CMD ["npm", "run", "dev"] diff --git a/litellm-js/spend-logs/README.md b/litellm-js/spend-logs/README.md deleted file mode 100644 index e12b31db70..0000000000 --- a/litellm-js/spend-logs/README.md +++ /dev/null @@ -1,8 +0,0 @@ -``` -npm install -npm run dev -``` - -``` -open http://localhost:3000 -``` diff --git a/litellm-js/spend-logs/package-lock.json b/litellm-js/spend-logs/package-lock.json deleted file mode 100644 index e9fc4b00f6..0000000000 --- a/litellm-js/spend-logs/package-lock.json +++ /dev/null @@ -1,597 +0,0 @@ -{ - "name": "spend-logs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@hono/node-server": "1.19.13", - "hono": "4.12.16" - }, - "devDependencies": { - "@types/node": "20.19.25", - "tsx": "4.20.6" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/litellm-js/spend-logs/package.json b/litellm-js/spend-logs/package.json deleted file mode 100644 index 5a7a95c5de..0000000000 --- a/litellm-js/spend-logs/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "scripts": { - "dev": "tsx watch src/index.ts" - }, - "dependencies": { - "@hono/node-server": "1.19.13", - "hono": "4.12.16" - }, - "devDependencies": { - "@types/node": "20.19.25", - "tsx": "4.20.6" - } -} diff --git a/litellm-js/spend-logs/schema.prisma b/litellm-js/spend-logs/schema.prisma deleted file mode 100644 index b0403f277a..0000000000 --- a/litellm-js/spend-logs/schema.prisma +++ /dev/null @@ -1,29 +0,0 @@ -generator client { - provider = "prisma-client-js" -} - -datasource client { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model LiteLLM_SpendLogs { - request_id String @id - call_type String - api_key String @default("") - spend Float @default(0.0) - total_tokens Int @default(0) - prompt_tokens Int @default(0) - completion_tokens Int @default(0) - startTime DateTime - endTime DateTime - model String @default("") - api_base String @default("") - user String @default("") - metadata Json @default("{}") - cache_hit String @default("") - cache_key String @default("") - request_tags Json @default("[]") - team_id String? - end_user String? -} \ No newline at end of file diff --git a/litellm-js/spend-logs/src/_types.ts b/litellm-js/spend-logs/src/_types.ts deleted file mode 100644 index 6a9b499171..0000000000 --- a/litellm-js/spend-logs/src/_types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LiteLLM_IncrementSpend = { - key_transactions: Array, // [{"key": spend},..] - user_transactions: Array, - team_transactions: Array, - spend_logs_transactions: Array -} - -export type LiteLLM_IncrementObject = { - key: string, - spend: number -} - -export type LiteLLM_SpendLogs = { - request_id: string; // @id means it's a unique identifier - call_type: string; - api_key: string; // @default("") means it defaults to an empty string if not provided - spend: number; // Float in Prisma corresponds to number in TypeScript - total_tokens: number; // Int in Prisma corresponds to number in TypeScript - prompt_tokens: number; - completion_tokens: number; - startTime: Date; // DateTime in Prisma corresponds to Date in TypeScript - endTime: Date; - model: string; // @default("") means it defaults to an empty string if not provided - api_base: string; - user: string; - metadata: any; // Json type in Prisma is represented by any in TypeScript; could also use a more specific type if the structure of JSON is known - cache_hit: string; - cache_key: string; - request_tags: any; // Similarly, this could be an array or a more specific type depending on the expected structure - team_id?: string | null; // ? indicates it's optional and can be undefined, but could also be null if not provided - end_user?: string | null; -}; \ No newline at end of file diff --git a/litellm-js/spend-logs/src/index.ts b/litellm-js/spend-logs/src/index.ts deleted file mode 100644 index 3581d95c83..0000000000 --- a/litellm-js/spend-logs/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { PrismaClient } from '@prisma/client' -import {LiteLLM_SpendLogs, LiteLLM_IncrementSpend, LiteLLM_IncrementObject} from './_types' - -const app = new Hono() -const prisma = new PrismaClient() -// In-memory storage for logs -let spend_logs: LiteLLM_SpendLogs[] = []; -const key_logs: LiteLLM_IncrementObject[] = []; -const user_logs: LiteLLM_IncrementObject[] = []; -const transaction_logs: LiteLLM_IncrementObject[] = []; - - -app.get('/', (c) => { - return c.text('Hello Hono!') -}) - -const MIN_LOGS = 1; // Minimum number of logs needed to initiate a flush -const FLUSH_INTERVAL = 5000; // Time in ms to wait before trying to flush again -const BATCH_SIZE = 100; // Preferred size of each batch to write to the database -const MAX_LOGS_PER_INTERVAL = 1000; // Maximum number of logs to flush in a single interval - -const flushLogsToDb = async () => { - if (spend_logs.length >= MIN_LOGS) { - // Limit the logs to process in this interval to MAX_LOGS_PER_INTERVAL or less - const logsToProcess = spend_logs.slice(0, MAX_LOGS_PER_INTERVAL); - - for (let i = 0; i < logsToProcess.length; i += BATCH_SIZE) { - // Create subarray for current batch, ensuring it doesn't exceed the BATCH_SIZE - const batch = logsToProcess.slice(i, i + BATCH_SIZE); - - // Convert datetime strings to Date objects - const batchWithDates = batch.map(entry => ({ - ...entry, - startTime: new Date(entry.startTime), - endTime: new Date(entry.endTime), - // Repeat for any other DateTime fields you may have - })); - - await prisma.liteLLM_SpendLogs.createMany({ - data: batchWithDates, - }); - - console.log(`Flushed ${batch.length} logs to the DB.`); - } - - // Remove the processed logs from spend_logs - spend_logs = spend_logs.slice(logsToProcess.length); - - console.log(`${logsToProcess.length} logs processed. Remaining in queue: ${spend_logs.length}`); - } else { - // This will ensure it doesn't falsely claim "No logs to flush." when it's merely below the MIN_LOGS threshold. - if(spend_logs.length > 0) { - console.log(`Accumulating logs. Currently at ${spend_logs.length}, waiting for at least ${MIN_LOGS}.`); - } else { - console.log("No logs to flush."); - } - } -}; - -// Setup interval for attempting to flush the logs -setInterval(flushLogsToDb, FLUSH_INTERVAL); - -// Route to receive log messages -app.post('/spend/update', async (c) => { - const incomingLogs = await c.req.json(); - - spend_logs.push(...incomingLogs); - - console.log(`Received and stored ${incomingLogs.length} logs. Total logs in memory: ${spend_logs.length}`); - - return c.json({ message: `Successfully stored ${incomingLogs.length} logs` }); -}); - - - -const port = 3000 -console.log(`Server is running on port ${port}`) - -serve({ - fetch: app.fetch, - port -}) diff --git a/litellm-js/spend-logs/tsconfig.json b/litellm-js/spend-logs/tsconfig.json deleted file mode 100644 index 028c03b6a8..0000000000 --- a/litellm-js/spend-logs/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "types": [ - "node" - ], - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - } -} \ No newline at end of file diff --git a/litellm/__init__.py b/litellm/__init__.py index e61ef25057..fd3d47ec15 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -206,6 +206,7 @@ add_user_information_to_llm_headers: Optional[bool] = ( ) store_audit_logs = False # Enterprise feature, allow users to see audit logs skip_system_message_in_guardrail: bool = False +skip_tool_message_in_guardrail: bool = False ### end of callbacks ############# email: Optional[str] = ( @@ -388,6 +389,7 @@ anthropic_beta_headers_url: str = os.getenv( suppress_debug_info = False dynamodb_table_name: Optional[str] = None s3_callback_params: Optional[Dict] = None +s3_audit_callback_params: Optional[Dict] = None datadog_llm_observability_params: Optional[Union[DatadogLLMObsInitParams, Dict]] = None datadog_params: Optional[Union[DatadogInitParams, Dict]] = None aws_sqs_callback_params: Optional[Dict] = None @@ -414,6 +416,9 @@ custom_prometheus_metadata_labels: List[str] = [] custom_prometheus_tags: List[str] = [] prometheus_metrics_config: Optional[List] = None prometheus_emit_stream_label: bool = False +prometheus_end_user_metrics_max_series_per_metric: Optional[int] = 10000 +prometheus_end_user_metrics_ttl_seconds: Optional[float] = 3600.0 +prometheus_end_user_metrics_cleanup_interval_seconds: Optional[float] = 60.0 disable_add_prefix_to_prompt: bool = ( False # used by anthropic, to disable adding prefix to prompt ) diff --git a/litellm/constants.py b/litellm/constants.py index 6918e40cad..072c2c358f 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -161,6 +161,11 @@ MCP_STDIO_ALLOWED_COMMANDS: frozenset = frozenset( | (set(_MCP_STDIO_EXTRA_COMMANDS.split(",")) - {""}) ) +# MCP OAuth2 Token Exchange (OBO) Defaults +MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE = int( + os.getenv("MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE", "500") +) + LITELLM_UI_ALLOW_HEADERS = [ "x-litellm-semantic-filter", "x-litellm-semantic-filter-tools", @@ -1457,6 +1462,12 @@ KEY_ROTATION_JOB_NAME = "litellm_key_rotation_job" EXPIRED_UI_SESSION_KEY_CLEANUP_JOB_NAME = "litellm_expired_ui_session_key_cleanup_job" SPEND_LOG_RUN_LOOPS = int(os.getenv("SPEND_LOG_RUN_LOOPS", 500)) SPEND_LOG_CLEANUP_BATCH_SIZE = int(os.getenv("SPEND_LOG_CLEANUP_BATCH_SIZE", 1000)) +SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES = int( + os.getenv("SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES", 3) +) +SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS = float( + os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5) +) SPEND_LOG_QUEUE_SIZE_THRESHOLD = int(os.getenv("SPEND_LOG_QUEUE_SIZE_THRESHOLD", 100)) SPEND_LOG_QUEUE_POLL_INTERVAL = float(os.getenv("SPEND_LOG_QUEUE_POLL_INTERVAL", 2.0)) SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE = int( diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index e703a3956b..0dc56b6a3b 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -366,6 +366,8 @@ class MCPClient: headers["Authorization"] = f"Bearer {self._mcp_auth_value}" elif self.auth_type == MCPAuth.token: headers["Authorization"] = f"token {self._mcp_auth_value}" + elif self.auth_type == MCPAuth.oauth2_token_exchange: + headers["Authorization"] = f"Bearer {self._mcp_auth_value}" elif isinstance(self._mcp_auth_value, dict): headers.update(self._mcp_auth_value) # Note: aws_sigv4 auth is not handled here — SigV4 requires per-request diff --git a/litellm/integrations/azure_sentinel/azure_sentinel.py b/litellm/integrations/azure_sentinel/azure_sentinel.py index dd508e6c6c..0cfd49cda3 100644 --- a/litellm/integrations/azure_sentinel/azure_sentinel.py +++ b/litellm/integrations/azure_sentinel/azure_sentinel.py @@ -14,16 +14,18 @@ For batching specific details see CustomBatchLogger class import asyncio import os +import time import traceback -from typing import List, Optional +from typing import List, Optional, Union from litellm._logging import verbose_logger from litellm.integrations.custom_batch_logger import CustomBatchLogger +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.types.utils import StandardLoggingPayload +from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload class AzureSentinelLogger(CustomBatchLogger): @@ -39,6 +41,7 @@ class AzureSentinelLogger(CustomBatchLogger): tenant_id: Optional[str] = None, client_id: Optional[str] = None, client_secret: Optional[str] = None, + audit_stream_name: Optional[str] = None, **kwargs, ): """ @@ -57,57 +60,77 @@ class AzureSentinelLogger(CustomBatchLogger): If not provided, will use AZURE_SENTINEL_CLIENT_ID or AZURE_CLIENT_ID env var. client_secret (str, optional): Azure Client Secret for OAuth2 authentication. If not provided, will use AZURE_SENTINEL_CLIENT_SECRET or AZURE_CLIENT_SECRET env var. + audit_stream_name (str, optional): Stream name from DCR for audit logs. + If not provided, audit logs use the standard stream name. """ self.async_httpx_client = get_async_httpx_client( llm_provider=httpxSpecialProvider.LoggingCallback ) - self.dcr_immutable_id = dcr_immutable_id or os.getenv( + resolved_dcr_immutable_id = dcr_immutable_id or os.getenv( "AZURE_SENTINEL_DCR_IMMUTABLE_ID" ) - self.stream_name = stream_name or os.getenv( - "AZURE_SENTINEL_STREAM_NAME", "Custom-LiteLLM" + resolved_stream_name = ( + stream_name or os.getenv("AZURE_SENTINEL_STREAM_NAME") or "Custom-LiteLLM" ) - self.endpoint = endpoint or os.getenv("AZURE_SENTINEL_ENDPOINT") - self.tenant_id = ( + resolved_audit_stream_name = audit_stream_name or resolved_stream_name + resolved_endpoint = endpoint or os.getenv("AZURE_SENTINEL_ENDPOINT") + resolved_tenant_id = ( tenant_id or os.getenv("AZURE_SENTINEL_TENANT_ID") or os.getenv("AZURE_TENANT_ID") ) - self.client_id = ( + resolved_client_id = ( client_id or os.getenv("AZURE_SENTINEL_CLIENT_ID") or os.getenv("AZURE_CLIENT_ID") ) - self.client_secret = ( + resolved_client_secret = ( client_secret or os.getenv("AZURE_SENTINEL_CLIENT_SECRET") or os.getenv("AZURE_CLIENT_SECRET") ) - if not self.dcr_immutable_id: + if not resolved_dcr_immutable_id: raise ValueError( "AZURE_SENTINEL_DCR_IMMUTABLE_ID is required. Set it as an environment variable or pass dcr_immutable_id parameter." ) - if not self.endpoint: + if not resolved_endpoint: raise ValueError( "AZURE_SENTINEL_ENDPOINT is required. Set it as an environment variable or pass endpoint parameter." ) - if not self.tenant_id: + if not resolved_tenant_id: raise ValueError( "AZURE_SENTINEL_TENANT_ID or AZURE_TENANT_ID is required. Set it as an environment variable or pass tenant_id parameter." ) - if not self.client_id: + if not resolved_client_id: raise ValueError( "AZURE_SENTINEL_CLIENT_ID or AZURE_CLIENT_ID is required. Set it as an environment variable or pass client_id parameter." ) - if not self.client_secret: + if not resolved_client_secret: raise ValueError( "AZURE_SENTINEL_CLIENT_SECRET or AZURE_CLIENT_SECRET is required. Set it as an environment variable or pass client_secret parameter." ) + self.dcr_immutable_id = resolved_dcr_immutable_id + self.stream_name = resolved_stream_name + self.audit_stream_name = resolved_audit_stream_name + self.endpoint = resolved_endpoint + self.tenant_id = resolved_tenant_id + self.client_id = resolved_client_id + self.client_secret = resolved_client_secret + # Build API endpoint: {Endpoint}/dataCollectionRules/{DCR Immutable ID}/streams/{Stream Name}?api-version=2023-01-01 - self.api_endpoint = f"{self.endpoint.rstrip('/')}/dataCollectionRules/{self.dcr_immutable_id}/streams/{self.stream_name}?api-version=2023-01-01" + self.api_endpoint = self._build_api_endpoint( + endpoint=resolved_endpoint, + dcr_immutable_id=resolved_dcr_immutable_id, + stream_name=resolved_stream_name, + ) + self.audit_api_endpoint = self._build_api_endpoint( + endpoint=resolved_endpoint, + dcr_immutable_id=resolved_dcr_immutable_id, + stream_name=resolved_audit_stream_name, + ) # OAuth2 scope for Azure Monitor self.oauth_scope = "https://monitor.azure.com/.default" @@ -118,6 +141,13 @@ class AzureSentinelLogger(CustomBatchLogger): super().__init__(**kwargs, flush_lock=self.flush_lock) asyncio.create_task(self.periodic_flush()) self.log_queue: List[StandardLoggingPayload] = [] + self.audit_log_queue: List[StandardAuditLogPayload] = [] + + @staticmethod + def _build_api_endpoint( + endpoint: str, dcr_immutable_id: str, stream_name: str + ) -> str: + return f"{endpoint.rstrip('/')}/dataCollectionRules/{dcr_immutable_id}/streams/{stream_name}?api-version=2023-01-01" async def _get_oauth_token(self) -> str: """ @@ -126,9 +156,6 @@ class AzureSentinelLogger(CustomBatchLogger): Returns: Bearer token string """ - # Check if we have a valid cached token - import time - if ( self.oauth_token and self.oauth_token_expires_at @@ -170,9 +197,6 @@ class AzureSentinelLogger(CustomBatchLogger): if not self.oauth_token: raise Exception("OAuth2 token response did not contain access_token") - # Cache token expiry time - import time - self.oauth_token_expires_at = time.time() + expires_in return self.oauth_token @@ -246,6 +270,34 @@ class AzureSentinelLogger(CustomBatchLogger): ) pass + async def async_log_audit_log_event( + self, audit_log: StandardAuditLogPayload + ) -> None: + """ + Async log LiteLLM audit log events to Azure Sentinel. + + Audit logs are queued separately from standard LLM logs so mixed callback + usage never sends schema-mismatched records in the same ingestion batch. + """ + try: + verbose_logger.debug( + "Azure Sentinel: Logging audit event id=%s action=%s table=%s", + audit_log.get("id"), + audit_log.get("action"), + audit_log.get("table_name"), + ) + + self.audit_log_queue.append(audit_log) + + if len(self.audit_log_queue) >= self.batch_size: + await self.async_send_audit_batch() + + except Exception as e: + verbose_logger.exception( + f"Azure Sentinel Audit Log Layer Error - {str(e)}\n{traceback.format_exc()}" + ) + pass + async def async_send_batch(self): """ Sends the batch of logs to Azure Monitor Logs Ingestion API @@ -253,22 +305,42 @@ class AzureSentinelLogger(CustomBatchLogger): Raises: Raises a NON Blocking verbose_logger.exception if an error occurs """ + await self._async_send_batch_to_api( + log_queue=self.log_queue, + api_endpoint=self.api_endpoint, + log_type="logs", + ) + + async def async_send_audit_batch(self): + """ + Sends the batch of audit logs to Azure Monitor Logs Ingestion API + """ + await self._async_send_batch_to_api( + log_queue=self.audit_log_queue, + api_endpoint=self.audit_api_endpoint, + log_type="audit logs", + ) + + async def _async_send_batch_to_api( + self, + log_queue: List[Union[StandardLoggingPayload, StandardAuditLogPayload]], + api_endpoint: str, + log_type: str, + ) -> None: try: - if not self.log_queue: + if not log_queue: return verbose_logger.debug( - "Azure Sentinel - about to flush %s events", len(self.log_queue) + "Azure Sentinel - about to flush %s %s", len(log_queue), log_type ) - from litellm.litellm_core_utils.safe_json_dumps import safe_dumps - # Get OAuth2 token bearer_token = await self._get_oauth_token() # Convert log queue to JSON array format expected by Logs Ingestion API # Each log entry should be a JSON object in the array - body = safe_dumps(self.log_queue) + body = safe_dumps(log_queue) # Set headers for Logs Ingestion API headers = { @@ -278,7 +350,7 @@ class AzureSentinelLogger(CustomBatchLogger): # Send the request response = await self.async_httpx_client.post( - url=self.api_endpoint, data=body.encode("utf-8"), headers=headers + url=api_endpoint, data=body.encode("utf-8"), headers=headers ) if response.status_code not in [200, 204]: @@ -301,4 +373,15 @@ class AzureSentinelLogger(CustomBatchLogger): f"Azure Sentinel Error sending batch API - {str(e)}\n{traceback.format_exc()}" ) finally: - self.log_queue.clear() + log_queue.clear() + + async def flush_queue(self): + if self.flush_lock is None: + return + + async with self.flush_lock: + if self.log_queue: + await self.async_send_batch() + if self.audit_log_queue: + await self.async_send_audit_batch() + self.last_flush_time = time.time() diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8385d51237..abce3c3e1c 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -57,6 +57,17 @@ LITELLM_PROXY_REQUEST_SPAN_NAME = "Received Proxy Server Request" RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request" LITELLM_REQUEST_SPAN_NAME = "litellm_request" +CAPTURE_MODE_NO_CONTENT = "NO_CONTENT" +CAPTURE_MODE_SPAN_ONLY = "SPAN_ONLY" +CAPTURE_MODE_EVENT_ONLY = "EVENT_ONLY" +CAPTURE_MODE_SPAN_AND_EVENT = "SPAN_AND_EVENT" +_VALID_CAPTURE_MODES = { + CAPTURE_MODE_NO_CONTENT, + CAPTURE_MODE_SPAN_ONLY, + CAPTURE_MODE_EVENT_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, +} + @dataclass class OpenTelemetryConfig: @@ -71,6 +82,9 @@ class OpenTelemetryConfig: ignore_context_propagation: Optional[bool] = None # When True, create a private TracerProvider instead of reusing or setting the global one. skip_set_global: bool = False + # Programmatic override for OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT. + # One of NO_CONTENT, SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT (or "true" as legacy alias). + capture_message_content: Optional[str] = None def __post_init__(self) -> None: # If endpoint is specified but exporter is still the default "console", @@ -182,6 +196,9 @@ class OpenTelemetry(CustomLogger): super().__init__(**kwargs) self._init_metrics(meter_provider) self._init_logs(logger_provider) + # Sample env-var / config / message_logging at init so subsequent + # _capture_in_span / _capture_in_event calls are deterministic. + self._capture_mode_cached = self._compute_capture_mode_from_init_state() self._init_otel_logger_on_litellm_proxy() @staticmethod @@ -306,6 +323,62 @@ class OpenTelemetry(CustomLogger): hasattr(self, "callback_name") and self.callback_name == "langfuse_otel" ) + def _compute_capture_mode_from_init_state(self) -> Optional[str]: + """Sample explicit settings at init. Returns the resolved mode or + None if nothing explicit is set (in which case the legacy + ``self.message_logging`` flag is consulted dynamically per request). + + ``"true"``/``"1"`` map to ``EVENT_ONLY`` per the contrib convention. + ``"false"``/``"0"`` map to ``NO_CONTENT``. + Unknown values are ignored. + """ + explicit = self.config.capture_message_content or os.getenv( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + ) + if not explicit: + return None + normalized = explicit.upper() + if normalized in ("TRUE", "1"): + return CAPTURE_MODE_EVENT_ONLY + if normalized in ("FALSE", "0"): + return CAPTURE_MODE_NO_CONTENT + if normalized in _VALID_CAPTURE_MODES: + return normalized + return None + + def _resolve_capture_mode(self) -> str: + """Return the active capture mode for this request. + + Precedence: + 1. ``litellm.turn_off_message_logging=True`` forces ``NO_CONTENT`` + (kill-switch checked dynamically). + 2. Explicit setting sampled at init from + ``OpenTelemetryConfig.capture_message_content`` or + ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``. + 3. Legacy ``self.message_logging`` (checked dynamically). + """ + if litellm.turn_off_message_logging: + return CAPTURE_MODE_NO_CONTENT + if self._capture_mode_cached is not None: + return self._capture_mode_cached + return ( + CAPTURE_MODE_SPAN_AND_EVENT + if self.message_logging + else CAPTURE_MODE_NO_CONTENT + ) + + def _capture_in_span(self) -> bool: + return self._resolve_capture_mode() in ( + CAPTURE_MODE_SPAN_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, + ) + + def _capture_in_event(self) -> bool: + return self._resolve_capture_mode() in ( + CAPTURE_MODE_EVENT_ONLY, + CAPTURE_MODE_SPAN_AND_EVENT, + ) + def _init_tracing(self, tracer_provider): from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -825,8 +898,7 @@ class OpenTelemetry(CustomLogger): from opentelemetry import trace from opentelemetry.trace import Status, StatusCode - # only log raw LLM request/response if message_logging is on and not globally turned off - if litellm.turn_off_message_logging or not self.message_logging: + if not self._capture_in_span(): return litellm_params = kwargs.get("litellm_params", {}) @@ -1117,9 +1189,14 @@ class OpenTelemetry(CustomLogger): } if role == "tool" and msg.get("id"): attrs["id"] = msg["id"] - if self.message_logging and msg.get("content"): + capture_event_content = self._capture_in_event() + if capture_event_content and msg.get("content"): attrs["gen_ai.prompt"] = msg["content"] + body = msg.copy() + if not capture_event_content: + body.pop("content", None) + log_record = SdkLogRecord( timestamp=self._to_ns(datetime.now()), trace_id=parent_ctx.trace_id, @@ -1127,7 +1204,7 @@ class OpenTelemetry(CustomLogger): trace_flags=parent_ctx.trace_flags, severity_number=SeverityNumber.INFO, severity_text="INFO", - body=msg.copy(), + body=body, attributes=attrs, ) otel_logger.emit(log_record) @@ -1141,14 +1218,15 @@ class OpenTelemetry(CustomLogger): "finish_reason": choice.get("finish_reason"), } body_msg = choice.get("message", {}) - if self.message_logging and body_msg.get("content"): + capture_event_content = self._capture_in_event() + if capture_event_content and body_msg.get("content"): attrs["message.content"] = body_msg["content"] body = { "index": idx, "finish_reason": choice.get("finish_reason"), "message": {"role": body_msg.get("role", "assistant")}, } - if self.message_logging and body_msg.get("content"): + if capture_event_content and body_msg.get("content"): body["message"]["content"] = body_msg["content"] log_record = SdkLogRecord( @@ -1674,9 +1752,7 @@ class OpenTelemetry(CustomLogger): ########## LLM Request Medssages / tools / content Attributes ########### ######################################################################### - if litellm.turn_off_message_logging is True: - return - if self.message_logging is not True: + if not self._capture_in_span(): return if optional_params.get("tools"): diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 7d3856ea63..f9b1c66643 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -25,6 +25,9 @@ from typing import ( import litellm from litellm._logging import print_verbose, verbose_logger from litellm.integrations.custom_logger import CustomLogger +from litellm.integrations.prometheus_helpers.bounded_prometheus_series_tracker import ( + BoundedPrometheusSeriesTracker, +) from litellm.integrations.prometheus_helpers import ( PrometheusLabelFactoryContext, _get_cached_end_user_id_for_cost_tracking, @@ -81,6 +84,7 @@ class PrometheusLogger(CustomLogger): if _custom_buckets is not None else LATENCY_BUCKETS ) + self._bounded_prometheus_series_tracker = BoundedPrometheusSeriesTracker() # Create metric factory functions self._counter_factory = self._create_metric_factory(Counter) @@ -984,6 +988,40 @@ class PrometheusLogger(CustomLogger): return filtered_labels + def _track_end_user_metric_series( + self, + metric: Any, + metric_name: DEFINED_PROMETHEUS_METRICS, + labels: Dict[str, Optional[str]], + ) -> None: + """ + Cap the cardinality of metrics that include the ``end_user`` label. + + Called *after* ``metric.labels(...).inc()/observe()`` so the emission is + recorded in prometheus-client's child map before any eviction runs. + Series that get evicted before the next scrape lose updates accrued + since the last scrape — this is inherent to any cardinality cap. + """ + labelnames = self.get_labels_for_metric(metric_name) + if UserAPIKeyLabelNames.END_USER.value not in labelnames: + return + if labels.get(UserAPIKeyLabelNames.END_USER.value) is None: + return + + max_series = litellm.prometheus_end_user_metrics_max_series_per_metric + ttl_seconds = litellm.prometheus_end_user_metrics_ttl_seconds + if max_series is None and ttl_seconds is None: + return + + self._bounded_prometheus_series_tracker.track_series( + metric=metric, + metric_name=metric_name, + label_values=tuple(labels.get(label) for label in labelnames), + max_series=max_series, + ttl_seconds=ttl_seconds, + cleanup_interval_seconds=litellm.prometheus_end_user_metrics_cleanup_interval_seconds, + ) + def _inc_labeled_counter( self, counter: Any, @@ -998,6 +1036,7 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) counter.labels(**_labels).inc(amount) + self._track_end_user_metric_series(counter, metric_name, _labels) async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): # Define prometheus client @@ -1047,21 +1086,9 @@ class PrometheusLogger(CustomLogger): output_tokens = standard_logging_payload["completion_tokens"] tokens_used = standard_logging_payload["total_tokens"] response_cost = standard_logging_payload["response_cost"] - _requester_metadata: Optional[dict] = standard_logging_payload["metadata"].get( - "requester_metadata" + combined_metadata = _get_combined_custom_metadata_from_standard_logging_payload( + standard_logging_payload=standard_logging_payload ) - user_api_key_auth_metadata: Optional[dict] = standard_logging_payload[ - "metadata" - ].get("user_api_key_auth_metadata") - spend_logs_metadata: Optional[dict] = standard_logging_payload["metadata"].get( - "spend_logs_metadata" - ) - - combined_metadata: Dict[str, Any] = { - **(_requester_metadata if _requester_metadata else {}), - **(user_api_key_auth_metadata if user_api_key_auth_metadata else {}), - **(spend_logs_metadata if spend_logs_metadata else {}), - } if standard_logging_payload is not None and isinstance( standard_logging_payload, dict ): @@ -1416,26 +1443,46 @@ class PrometheusLogger(CustomLogger): ) remaining_tokens_variable_name = f"litellm-key-remaining-tokens-{model_group}" - remaining_requests = ( - metadata.get(remaining_requests_variable_name, sys.maxsize) or sys.maxsize + remaining_requests = metadata.get(remaining_requests_variable_name) + if remaining_requests is None: + remaining_requests = sys.maxsize + remaining_tokens = metadata.get(remaining_tokens_variable_name) + if remaining_tokens is None: + remaining_tokens = sys.maxsize + + enum_values = UserAPIKeyLabelValues( + hashed_api_key=user_api_key, + api_key_alias=user_api_key_alias, + model=model_group, + model_id=model_id, + custom_metadata_labels=get_custom_labels_from_metadata( + metadata=_get_combined_custom_metadata_from_standard_logging_payload( + standard_logging_payload=kwargs.get("standard_logging_object") + ) + ), ) - remaining_tokens = ( - metadata.get(remaining_tokens_variable_name, sys.maxsize) or sys.maxsize + label_context = PrometheusLabelFactoryContext(enum_values) + requests_labels = prometheus_label_factory( + supported_enum_labels=self.get_labels_for_metric( + "litellm_remaining_api_key_requests_for_model" + ), + enum_values=enum_values, + label_context=label_context, + ) + self.litellm_remaining_api_key_requests_for_model.labels(**requests_labels).set( + remaining_requests ) - self.litellm_remaining_api_key_requests_for_model.labels( - _sanitize_prometheus_label_value(user_api_key), - _sanitize_prometheus_label_value(user_api_key_alias), - _sanitize_prometheus_label_value(model_group), - _sanitize_prometheus_label_value(model_id), - ).set(remaining_requests) - - self.litellm_remaining_api_key_tokens_for_model.labels( - _sanitize_prometheus_label_value(user_api_key), - _sanitize_prometheus_label_value(user_api_key_alias), - _sanitize_prometheus_label_value(model_group), - _sanitize_prometheus_label_value(model_id), - ).set(remaining_tokens) + tokens_labels = prometheus_label_factory( + supported_enum_labels=self.get_labels_for_metric( + "litellm_remaining_api_key_tokens_for_model" + ), + enum_values=enum_values, + label_context=label_context, + ) + self.litellm_remaining_api_key_tokens_for_model.labels(**tokens_labels).set( + remaining_tokens + ) def _set_latency_metrics( self, @@ -1471,6 +1518,11 @@ class PrometheusLogger(CustomLogger): self.litellm_llm_api_time_to_first_token_metric.labels( **_ttft_labels ).observe(time_to_first_token_seconds) + self._track_end_user_metric_series( + self.litellm_llm_api_time_to_first_token_metric, + "litellm_llm_api_time_to_first_token_metric", + _ttft_labels, + ) else: verbose_logger.debug( "Time to first token metric not emitted, stream option in model_parameters is not True" @@ -1491,6 +1543,11 @@ class PrometheusLogger(CustomLogger): self.litellm_llm_api_latency_metric.labels(**_labels).observe( api_call_total_time_seconds ) + self._track_end_user_metric_series( + self.litellm_llm_api_latency_metric, + "litellm_llm_api_latency_metric", + _labels, + ) # total request latency total_time_seconds = self._safe_duration_seconds( @@ -1508,6 +1565,11 @@ class PrometheusLogger(CustomLogger): self.litellm_request_total_latency_metric.labels(**_labels).observe( total_time_seconds ) + self._track_end_user_metric_series( + self.litellm_request_total_latency_metric, + "litellm_request_total_latency_metric", + _labels, + ) # request queue time (time from arrival to processing start) _litellm_params = kwargs.get("litellm_params", {}) or {} @@ -1525,6 +1587,11 @@ class PrometheusLogger(CustomLogger): self.litellm_request_queue_time_metric.labels(**_labels).observe( queue_time_seconds ) + self._track_end_user_metric_series( + self.litellm_request_queue_time_metric, + "litellm_request_queue_time_seconds", + _labels, + ) async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): verbose_logger.debug( @@ -1561,18 +1628,27 @@ class PrometheusLogger(CustomLogger): ) try: - self.litellm_llm_api_failed_requests_metric.labels( - _sanitize_prometheus_label_value(end_user_id), - _sanitize_prometheus_label_value(user_api_key), - _sanitize_prometheus_label_value(user_api_key_alias), - _sanitize_prometheus_label_value(model), - _sanitize_prometheus_label_value(user_api_team), - _sanitize_prometheus_label_value(user_api_team_alias), - _sanitize_prometheus_label_value(user_id), - _sanitize_prometheus_label_value( - standard_logging_payload.get("model_id", "") + enum_values = UserAPIKeyLabelValues( + end_user=end_user_id, + hashed_api_key=user_api_key, + api_key_alias=user_api_key_alias, + model=model, + team=user_api_team, + team_alias=user_api_team_alias, + user=user_id, + model_id=standard_logging_payload.get("model_id", ""), + custom_metadata_labels=get_custom_labels_from_metadata( + metadata=_get_combined_custom_metadata_from_standard_logging_payload( + standard_logging_payload=standard_logging_payload + ) ), - ).inc() + ) + PrometheusLogger._inc_labeled_counter( + self, + self.litellm_llm_api_failed_requests_metric, + "litellm_llm_api_failed_requests_metric", + enum_values, + ) self.set_llm_deployment_failure_metrics(kwargs) await self._set_org_budget_metrics_after_api_request( org_id=user_api_key_org_id, @@ -3622,6 +3698,36 @@ def get_custom_labels_from_metadata(metadata: dict) -> Dict[str, str]: return result +def _get_combined_custom_metadata_from_standard_logging_payload( + standard_logging_payload: Optional[dict], +) -> Dict[str, Any]: + """ + Combine the metadata sources that can supply custom Prometheus labels. + """ + if not isinstance(standard_logging_payload, dict): + return {} + + standard_logging_metadata = standard_logging_payload.get("metadata") or {} + if not isinstance(standard_logging_metadata, dict): + return {} + + requester_metadata = standard_logging_metadata.get("requester_metadata") + user_api_key_auth_metadata = standard_logging_metadata.get( + "user_api_key_auth_metadata" + ) + spend_logs_metadata = standard_logging_metadata.get("spend_logs_metadata") + + return { + **(requester_metadata if isinstance(requester_metadata, dict) else {}), + **( + user_api_key_auth_metadata + if isinstance(user_api_key_auth_metadata, dict) + else {} + ), + **(spend_logs_metadata if isinstance(spend_logs_metadata, dict) else {}), + } + + def _tag_matches_wildcard_configured_pattern( tags: Sequence[str], configured_tag: str ) -> bool: diff --git a/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py b/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py new file mode 100644 index 0000000000..d834ae2014 --- /dev/null +++ b/litellm/integrations/prometheus_helpers/bounded_prometheus_series_tracker.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import time +from collections import OrderedDict +from threading import RLock +from typing import Any, Dict, Optional + + +class BoundedPrometheusSeriesTracker: + """ + Tracks Prometheus child series and removes stale/excess labelsets. + + The tracker is label-agnostic: callers decide which series should be tracked + and pass the full label tuple used by the Prometheus metric. + """ + + def __init__(self) -> None: + self._series: Dict[str, OrderedDict[tuple[Optional[str], ...], float]] = {} + self._last_ttl_cleanup: Dict[str, float] = {} + self.lock = RLock() + + def track_series( + self, + metric: Any, + metric_name: str, + label_values: tuple[Optional[str], ...], + max_series: Optional[int], + ttl_seconds: Optional[float], + cleanup_interval_seconds: Optional[float], + ) -> None: + if max_series is None and ttl_seconds is None: + return + + now = time.monotonic() + + with self.lock: + series = self._series.setdefault(metric_name, OrderedDict()) + series[label_values] = now + series.move_to_end(label_values) + + if ttl_seconds is not None and self._should_run_ttl_cleanup( + metric_name=metric_name, + now=now, + cleanup_interval_seconds=cleanup_interval_seconds, + ): + expired_label_values = [ + tracked_label_values + for tracked_label_values, last_seen in series.items() + if now - last_seen > ttl_seconds + ] + for tracked_label_values in expired_label_values: + self._remove_metric_series(metric, series, tracked_label_values) + + # max_series <= 0 is treated as "unlimited" so a misconfigured zero + # value cannot silently drop every emission for this metric. + if max_series is not None and max_series > 0: + while len(series) > max_series: + tracked_label_values = next(iter(series)) + if not self._remove_metric_child(metric, tracked_label_values): + break + del series[tracked_label_values] + + def _should_run_ttl_cleanup( + self, + metric_name: str, + now: float, + cleanup_interval_seconds: Optional[float], + ) -> bool: + if cleanup_interval_seconds is None or cleanup_interval_seconds <= 0: + self._last_ttl_cleanup[metric_name] = now + return True + + last_cleanup = self._last_ttl_cleanup.get(metric_name) + if last_cleanup is None or now - last_cleanup >= cleanup_interval_seconds: + self._last_ttl_cleanup[metric_name] = now + return True + return False + + def _remove_metric_series( + self, + metric: Any, + series: OrderedDict[tuple[Optional[str], ...], float], + label_values: tuple[Optional[str], ...], + ) -> None: + if self._remove_metric_child(metric, label_values): + series.pop(label_values, None) + + @staticmethod + def _remove_metric_child( + metric: Any, label_values: tuple[Optional[str], ...] + ) -> bool: + """ + Remove the Prometheus child for ``label_values`` and report whether the + tracker should commit the matching state change. + + Returns ``True`` when the child is no longer present in Prometheus + (either it was just removed or it was already gone), and ``False`` when + ``metric.remove()`` raised an unexpected error and the child likely + still exists. + """ + try: + metric.remove(*label_values) + return True + except KeyError: + return True + except (AttributeError, ValueError): + return False diff --git a/litellm/integrations/s3_v2.py b/litellm/integrations/s3_v2.py index 08ce7ed894..332e84dd07 100644 --- a/litellm/integrations/s3_v2.py +++ b/litellm/integrations/s3_v2.py @@ -16,6 +16,7 @@ from litellm._logging import print_verbose, verbose_logger from litellm.constants import DEFAULT_S3_BATCH_SIZE, DEFAULT_S3_FLUSH_INTERVAL_SECONDS from litellm.integrations.s3 import get_s3_object_key from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, @@ -53,15 +54,25 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, + s3_callback_params_override: Optional[dict] = None, **kwargs, ): try: - verbose_logger.debug( - f"in init s3 logger - s3_callback_params {litellm.s3_callback_params}" - ) + _masker = SensitiveDataMasker() + if s3_callback_params_override is not None: + verbose_logger.debug( + f"in init s3 logger (audit override) - " + f"{_masker.mask_dict(dict(s3_callback_params_override))}" + ) + else: + verbose_logger.debug( + f"in init s3 logger - s3_callback_params " + f"{_masker.mask_dict(dict(litellm.s3_callback_params or {}))}" + ) # Initialize S3 params first to get the correct s3_verify value self._init_s3_params( + params_source=s3_callback_params_override, s3_bucket_name=s3_bucket_name, s3_region_name=s3_region_name, s3_api_version=s3_api_version, @@ -139,94 +150,85 @@ class S3Logger(CustomBatchLogger, BaseAWSLLM): s3_strip_base64_files: bool = False, s3_use_key_prefix: bool = False, s3_use_virtual_hosted_style: bool = False, + params_source: Optional[dict] = None, ): """ - Initialize the s3 params for this logging callback + Initialize the s3 params for this logging callback. Reads from + `params_source` if given (e.g. `s3_audit_callback_params` for the + audit-log instance), otherwise falls back to `litellm.s3_callback_params`. + Resolves `os.environ/X` markers into a local dict; never mutates the source. """ - litellm.s3_callback_params = litellm.s3_callback_params or {} - # read in .env variables - example os.environ/AWS_BUCKET_NAME - for key, value in litellm.s3_callback_params.items(): - if isinstance(value, str) and value.startswith("os.environ/"): - litellm.s3_callback_params[key] = litellm.get_secret(value) + if params_source is None: + params_source = litellm.s3_callback_params or {} + params: dict = { + key: ( + litellm.get_secret(value) + if isinstance(value, str) and value.startswith("os.environ/") + else value + ) + for key, value in params_source.items() + } - self.s3_bucket_name = ( - litellm.s3_callback_params.get("s3_bucket_name") or s3_bucket_name - ) - self.s3_region_name = ( - litellm.s3_callback_params.get("s3_region_name") or s3_region_name - ) - self.s3_api_version = ( - litellm.s3_callback_params.get("s3_api_version") or s3_api_version - ) + self.s3_bucket_name = params.get("s3_bucket_name") or s3_bucket_name + self.s3_region_name = params.get("s3_region_name") or s3_region_name + self.s3_api_version = params.get("s3_api_version") or s3_api_version self.s3_use_ssl = ( - litellm.s3_callback_params.get("s3_use_ssl", True) - if litellm.s3_callback_params.get("s3_use_ssl") is not None + params.get("s3_use_ssl", True) + if params.get("s3_use_ssl") is not None else s3_use_ssl ) self.s3_verify = ( - litellm.s3_callback_params.get("s3_verify") - if litellm.s3_callback_params.get("s3_verify") is not None + params.get("s3_verify") + if params.get("s3_verify") is not None else s3_verify ) - self.s3_endpoint_url = ( - litellm.s3_callback_params.get("s3_endpoint_url") or s3_endpoint_url - ) + self.s3_endpoint_url = params.get("s3_endpoint_url") or s3_endpoint_url self.s3_aws_access_key_id = ( - litellm.s3_callback_params.get("s3_aws_access_key_id") - or s3_aws_access_key_id + params.get("s3_aws_access_key_id") or s3_aws_access_key_id ) self.s3_aws_secret_access_key = ( - litellm.s3_callback_params.get("s3_aws_secret_access_key") - or s3_aws_secret_access_key + params.get("s3_aws_secret_access_key") or s3_aws_secret_access_key ) self.s3_aws_session_token = ( - litellm.s3_callback_params.get("s3_aws_session_token") - or s3_aws_session_token + params.get("s3_aws_session_token") or s3_aws_session_token ) self.s3_aws_session_name = ( - litellm.s3_callback_params.get("s3_aws_session_name") or s3_aws_session_name + params.get("s3_aws_session_name") or s3_aws_session_name ) self.s3_aws_profile_name = ( - litellm.s3_callback_params.get("s3_aws_profile_name") or s3_aws_profile_name + params.get("s3_aws_profile_name") or s3_aws_profile_name ) - self.s3_aws_role_name = ( - litellm.s3_callback_params.get("s3_aws_role_name") or s3_aws_role_name - ) + self.s3_aws_role_name = params.get("s3_aws_role_name") or s3_aws_role_name self.s3_aws_web_identity_token = ( - litellm.s3_callback_params.get("s3_aws_web_identity_token") - or s3_aws_web_identity_token + params.get("s3_aws_web_identity_token") or s3_aws_web_identity_token ) self.s3_aws_sts_endpoint = ( - litellm.s3_callback_params.get("s3_aws_sts_endpoint") or s3_aws_sts_endpoint + params.get("s3_aws_sts_endpoint") or s3_aws_sts_endpoint ) - self.s3_config = litellm.s3_callback_params.get("s3_config") or s3_config - self.s3_path = litellm.s3_callback_params.get("s3_path") or s3_path - # done reading litellm.s3_callback_params + self.s3_config = params.get("s3_config") or s3_config + self.s3_path = params.get("s3_path") or s3_path self.s3_use_team_prefix = ( - bool(litellm.s3_callback_params.get("s3_use_team_prefix", False)) - or s3_use_team_prefix + bool(params.get("s3_use_team_prefix", False)) or s3_use_team_prefix ) self.s3_use_key_prefix = ( - bool(litellm.s3_callback_params.get("s3_use_key_prefix", False)) - or s3_use_key_prefix + bool(params.get("s3_use_key_prefix", False)) or s3_use_key_prefix ) self.s3_strip_base64_files = ( - bool(litellm.s3_callback_params.get("s3_strip_base64_files", False)) - or s3_strip_base64_files + bool(params.get("s3_strip_base64_files", False)) or s3_strip_base64_files ) self.s3_use_virtual_hosted_style = ( - bool(litellm.s3_callback_params.get("s3_use_virtual_hosted_style", False)) + bool(params.get("s3_use_virtual_hosted_style", False)) or s3_use_virtual_hosted_style ) diff --git a/litellm/litellm_core_utils/custom_logger_registry.py b/litellm/litellm_core_utils/custom_logger_registry.py index f873bfeece..fd402b90d8 100644 --- a/litellm/litellm_core_utils/custom_logger_registry.py +++ b/litellm/litellm_core_utils/custom_logger_registry.py @@ -14,6 +14,7 @@ from litellm import _custom_logger_compatible_callbacks_literal from litellm.integrations.agentops import AgentOps from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook from litellm.integrations.argilla import ArgillaLogger +from litellm.integrations.azure_sentinel.azure_sentinel import AzureSentinelLogger from litellm.integrations.azure_storage.azure_storage import AzureBlobStorageLogger from litellm.integrations.bitbucket import BitBucketPromptManager from litellm.integrations.braintrust_logging import BraintrustLogger @@ -73,6 +74,7 @@ class CustomLoggerRegistry: "opik": OpikLogger, "argilla": ArgillaLogger, "opentelemetry": OpenTelemetry, + "azure_sentinel": AzureSentinelLogger, "azure_storage": AzureBlobStorageLogger, "humanloop": HumanloopLogger, # OTEL compatible loggers diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b234e6c8f7..52269d705d 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -436,12 +436,21 @@ def update_messages_with_model_file_ids( """ Updates messages with model file ids. + For managed files (unified file IDs), uses model_file_id_mapping if it + resolves the id, otherwise decodes the base64-encoded unified file ID + and extracts the llm_output_file_id directly. Mirrors the Responses-API + sibling `update_responses_input_with_model_file_ids`. + model_file_id_mapping: Dict[str, Dict[str, str]] = { "litellm_proxy/file_id": { "model_id": "provider_file_id" } } """ + from litellm.proxy.openai_files_endpoints.common_utils import ( + _is_base64_encoded_unified_file_id, + convert_b64_uid_to_unified_uid, + ) for message in messages: if message.get("role") == "user": @@ -450,7 +459,13 @@ def update_messages_with_model_file_ids( if isinstance(content, str): continue for c in content: - if c["type"] == "file": + if not isinstance(c, dict): + # Content list items aren't always dicts. e.g. + # text_completion forwards a token-ids list/list-of- + # lists through this path. Skip non-dict items + # instead of indexing into them. + continue + if c.get("type") == "file": file_object = cast(ChatCompletionFileObject, c) file_object_file_field = file_object.get("file") if not isinstance(file_object_file_field, dict): @@ -468,9 +483,23 @@ def update_messages_with_model_file_ids( if file_id: provider_file_id = ( model_file_id_mapping.get(file_id, {}).get(model_id) - or file_id + if model_file_id_mapping + else None + ) + if ( + not provider_file_id + and _is_base64_encoded_unified_file_id(file_id) + ): + unified_file_id = convert_b64_uid_to_unified_uid( + file_id + ) + if "llm_output_file_id," in unified_file_id: + provider_file_id = unified_file_id.split( + "llm_output_file_id," + )[1].split(";")[0] + file_object_file_field["file_id"] = ( + provider_file_id or file_id ) - file_object_file_field["file_id"] = provider_file_id if format: file_object_file_field["format"] = format return messages diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 2bb82f227b..74dadee5ec 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -23,7 +23,9 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, @@ -108,6 +110,7 @@ class AnthropicMessagesHandler(BaseTranslation): return data skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply) chat_completion_compatible_request = self._translate_to_openai(data) @@ -117,6 +120,8 @@ class AnthropicMessagesHandler(BaseTranslation): ) if skip_system: structured_messages = openai_messages_without_system(structured_messages) + if skip_tool: + structured_messages = openai_messages_without_tool(structured_messages) texts_to_check: List[str] = [] images_to_check: List[str] = [] @@ -134,6 +139,7 @@ class AnthropicMessagesHandler(BaseTranslation): images_to_check=images_to_check, task_mappings=task_mappings, skip_system_message=skip_system, + skip_tool_message=skip_tool, ) # Step 2: Apply guardrail to all texts in batch @@ -198,13 +204,17 @@ class AnthropicMessagesHandler(BaseTranslation): images_to_check: List[str], task_mappings: List[Tuple[int, Optional[int]]], skip_system_message: bool = False, + skip_tool_message: bool = False, ) -> None: """ Extract text content and images from a message. Override this method to customize text/image extraction logic. """ - if skip_system_message and str(message.get("role") or "").lower() == "system": + role = str(message.get("role") or "").lower() + if skip_system_message and role == "system": + return + if skip_tool_message and role == "tool": return content = message.get("content", None) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 672de6a705..2fb29b32a6 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -579,6 +579,10 @@ class ModelResponseIterator: # Accumulate compaction blocks for multi-turn reconstruction self.compaction_blocks: List[Dict[str, Any]] = [] + # Accumulate streamed thinking text so final usage can split reasoning + # tokens from regular output tokens. + self.reasoning_content_chunks: List[str] = [] + # Track server tool use inputs and results for code_interpreter_results self._server_tool_inputs: Dict[str, Any] = {} self.tool_results: List[Dict[str, Any]] = [] @@ -609,9 +613,14 @@ class ModelResponseIterator: return False def _handle_usage(self, anthropic_usage_chunk: Union[dict, UsageDelta]) -> Usage: + reasoning_content = ( + "".join(self.reasoning_content_chunks) + if self.reasoning_content_chunks + else None + ) return AnthropicConfig().calculate_usage( usage_object=cast(dict, anthropic_usage_chunk), - reasoning_content=None, + reasoning_content=reasoning_content, speed=self.speed, ) @@ -658,10 +667,13 @@ class ModelResponseIterator: "thinking" in content_block["delta"] or "signature" in content_block["delta"] ): + thinking_content = content_block["delta"].get("thinking") + if isinstance(thinking_content, str) and thinking_content: + self.reasoning_content_chunks.append(thinking_content) thinking_blocks = [ ChatCompletionThinkingBlock( type="thinking", - thinking=content_block["delta"].get("thinking") or "", + thinking=thinking_content or "", signature=str(content_block["delta"].get("signature") or ""), ) ] diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index dc3100f467..2f11a3fccb 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2156,8 +2156,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): speed: Optional[str] = None, ) -> Usage: # NOTE: Sometimes the usage object has None set explicitly for token counts, meaning .get() & key access returns None, and we need to account for this - prompt_tokens = usage_object.get("input_tokens", 0) or 0 - completion_tokens = usage_object.get("output_tokens", 0) or 0 + raw_prompt_tokens = usage_object.get("input_tokens", 0) or 0 + prompt_tokens: int = ( + int(raw_prompt_tokens) if isinstance(raw_prompt_tokens, (int, float)) else 0 + ) + raw_completion_tokens = usage_object.get("output_tokens", 0) or 0 + completion_tokens: int = ( + int(raw_completion_tokens) + if isinstance(raw_completion_tokens, (int, float)) + else 0 + ) _usage = usage_object cache_creation_input_tokens: int = 0 cache_read_input_tokens: int = 0 @@ -2226,11 +2234,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): text_tokens=raw_input_tokens, ) # Always populate completion_token_details, not just when there's reasoning_content - reasoning_tokens = ( + estimated_reasoning_tokens = ( token_counter(text=reasoning_content, count_response_tokens=True) if reasoning_content else 0 ) + reasoning_tokens = min(estimated_reasoning_tokens, completion_tokens) completion_token_details = CompletionTokensDetailsWrapper( reasoning_tokens=reasoning_tokens if reasoning_tokens > 0 else 0, text_tokens=( diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index cdd2d77537..97ece6b5ea 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -14,7 +14,22 @@ def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool return bool(getattr(litellm, "skip_system_message_in_guardrail", False)) +def effective_skip_tool_message_for_guardrail(guardrail_to_apply: Any) -> bool: + per = getattr(guardrail_to_apply, "skip_tool_message_in_guardrail", None) + if per is not None: + return bool(per) + import litellm + + return bool(getattr(litellm, "skip_tool_message_in_guardrail", False)) + + def openai_messages_without_system( messages: List[AllMessageValues], ) -> List[AllMessageValues]: return [m for m in messages if str((m or {}).get("role") or "").lower() != "system"] + + +def openai_messages_without_tool( + messages: List[AllMessageValues], +) -> List[AllMessageValues]: + return [m for m in messages if str((m or {}).get("role") or "").lower() != "tool"] diff --git a/litellm/llms/bedrock/chat/invoke_agent/transformation.py b/litellm/llms/bedrock/chat/invoke_agent/transformation.py index 4c667b0ce3..e4072c2455 100644 --- a/litellm/llms/bedrock/chat/invoke_agent/transformation.py +++ b/litellm/llms/bedrock/chat/invoke_agent/transformation.py @@ -299,29 +299,9 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM): ) def _get_response_stream_shape(self): - """Get the response stream shape for parsing, reusing existing logic.""" - try: - # Try to reuse the cached shape from the existing decoder - from litellm.llms.bedrock.chat.invoke_handler import ( - get_response_stream_shape, - ) + from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE - return get_response_stream_shape() - except ImportError: - # Fallback: create our own shape - try: - from botocore.loaders import Loader - from botocore.model import ServiceModel - - loader = Loader() - bedrock_service_dict = loader.load_service_model( - "bedrock-runtime", "service-2" - ) - bedrock_service_model = ServiceModel(bedrock_service_dict) - return bedrock_service_model.shape_for("ResponseStream") - except Exception as e: - verbose_logger.warning(f"Could not load response stream shape: {e}") - return None + return BEDROCK_RESPONSE_STREAM_SHAPE def _extract_response_content(self, events: InvokeAgentEventList) -> str: """Extract the final response content from parsed events.""" diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 9dfada7c41..92ca75db95 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -67,9 +67,13 @@ from litellm.types.utils import ( from litellm.utils import CustomStreamWrapper, get_secret from ..base_aws_llm import BaseAWSLLM -from ..common_utils import BedrockError, ModelResponseIterator, get_bedrock_tool_name +from ..common_utils import ( + BEDROCK_RESPONSE_STREAM_SHAPE, + BedrockError, + ModelResponseIterator, + get_bedrock_tool_name, +) -_response_stream_shape_cache = None bedrock_tool_name_mappings: InMemoryCache = InMemoryCache( max_size_in_memory=50, default_ttl=600 ) @@ -1391,20 +1395,6 @@ class BedrockLLM(BaseAWSLLM): return None -def get_response_stream_shape(): - global _response_stream_shape_cache - if _response_stream_shape_cache is None: - from botocore.loaders import Loader - from botocore.model import ServiceModel - - loader = Loader() - bedrock_service_dict = loader.load_service_model("bedrock-runtime", "service-2") - bedrock_service_model = ServiceModel(bedrock_service_dict) - _response_stream_shape_cache = bedrock_service_model.shape_for("ResponseStream") - - return _response_stream_shape_cache - - class AWSEventStreamDecoder: def __init__(self, model: str, json_mode: Optional[bool] = False) -> None: from botocore.parsers import EventStreamJSONParser @@ -1838,8 +1828,18 @@ class AWSEventStreamDecoder: yield self._chunk_parser(chunk_data=_data) def _parse_message_from_event(self, event) -> Optional[str]: + if BEDROCK_RESPONSE_STREAM_SHAPE is None: + raise BedrockError( + status_code=500, + message=( + "Bedrock event-stream shape could not be loaded from botocore. " + "Ensure botocore is correctly installed." + ), + ) response_dict = event.to_response_dict() - parsed_response = self.parser.parse(response_dict, get_response_stream_shape()) + parsed_response = self.parser.parse( + response_dict, BEDROCK_RESPONSE_STREAM_SHAPE + ) if response_dict["status_code"] != 200: decoded_body = response_dict["body"].decode() diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 9a97a134cc..856a525f77 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: import httpx import litellm +from litellm import verbose_logger from litellm.llms.base_llm.anthropic_messages.transformation import ( BaseAnthropicMessagesConfig, ) @@ -917,38 +918,57 @@ def get_bedrock_chat_config(model: str): return litellm.AmazonInvokeConfig() +def _load_bedrock_response_stream_shape(): + """ + Load the ResponseStream shape from botocore's bundled bedrock-runtime schema. + + Called once at module import time; the result is stored in + ``BEDROCK_RESPONSE_STREAM_SHAPE`` and reused for the process lifetime. + Returns ``None`` if botocore is unavailable or the service model cannot be + loaded, so the module still imports cleanly. + """ + try: + from botocore.loaders import Loader + from botocore.model import ServiceModel + + loader = Loader() + service_dict = loader.load_service_model("bedrock-runtime", "service-2") + return ServiceModel(service_dict).shape_for("ResponseStream") + except Exception as e: + verbose_logger.warning( + "litellm: could not pre-load bedrock-runtime response stream shape " + "— Bedrock event-stream decoding will be unavailable. Error: %s", + e, + ) + return None + + +# Eagerly resolved once per process — avoids per-instance or per-request disk I/O. +BEDROCK_RESPONSE_STREAM_SHAPE = _load_bedrock_response_stream_shape() + + class BedrockEventStreamDecoderBase: """ Base class for event stream decoding for Bedrock """ - _response_stream_shape_cache = None - def __init__(self): from botocore.parsers import EventStreamJSONParser self.parser = EventStreamJSONParser() - def get_response_stream_shape(self): - if self._response_stream_shape_cache is None: - from botocore.loaders import Loader - from botocore.model import ServiceModel - - loader = Loader() - bedrock_service_dict = loader.load_service_model( - "bedrock-runtime", "service-2" - ) - bedrock_service_model = ServiceModel(bedrock_service_dict) - self._response_stream_shape_cache = bedrock_service_model.shape_for( - "ResponseStream" - ) - - return self._response_stream_shape_cache - def _parse_message_from_event(self, event) -> Optional[str]: + if BEDROCK_RESPONSE_STREAM_SHAPE is None: + raise BedrockError( + status_code=500, + message=( + "Bedrock event-stream shape could not be loaded from botocore. " + "Ensure botocore is correctly installed." + ), + ) response_dict = event.to_response_dict() parsed_response = self.parser.parse( - response_dict, self.get_response_stream_shape() + response_dict, BEDROCK_RESPONSE_STREAM_SHAPE ) if response_dict["status_code"] != 200: diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index dd955c23d2..af18c66667 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -1,4 +1,5 @@ import asyncio +import concurrent.futures import inspect import os import socket @@ -133,6 +134,11 @@ _DEFAULT_TIMEOUT = httpx.Timeout( timeout=COMPLETION_HTTP_FALLBACK_SECONDS, connect=HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS, ) +_STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS = 5.0 +_STREAMING_ERROR_BODY_READ_EXECUTOR = concurrent.futures.ThreadPoolExecutor( + max_workers=50, + thread_name_prefix="litellm-streaming-error-body-read", +) def _prepare_request_data_and_content( @@ -386,17 +392,30 @@ def _safe_get_response_text(response: httpx.Response) -> str: return "" -async def _safe_aread_response(response: httpx.Response) -> bytes: +async def _safe_aread_response( + response: httpx.Response, timeout: Optional[float] = None +) -> bytes: """Safely read async response body, falling back to empty bytes on errors.""" try: + if timeout is not None: + return await asyncio.wait_for(response.aread(), timeout=timeout) return await response.aread() except Exception: return b"" -def _safe_read_response(response: httpx.Response) -> bytes: +def _safe_read_response( + response: httpx.Response, timeout: Optional[float] = None +) -> bytes: """Safely read sync response body, falling back to empty bytes on errors.""" try: + if timeout is not None: + future = _STREAMING_ERROR_BODY_READ_EXECUTOR.submit(response.read) + try: + return future.result(timeout=timeout) + except Exception: + response.close() + return b"" return response.read() except Exception: return b"" @@ -405,8 +424,19 @@ def _safe_read_response(response: httpx.Response) -> bytes: def _raise_masked_sync_error(e: httpx.HTTPStatusError, stream: bool) -> None: """Raise a MaskedHTTPStatusError for sync HTTP handlers.""" if stream: - _body = mask_sensitive_info(_safe_read_response(e.response)) - raise MaskedHTTPStatusError(e, message=_body, text=_body) from None + try: + _body = mask_sensitive_info( + _safe_read_response( + e.response, + timeout=_STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS, + ) + ) + raise MaskedHTTPStatusError(e, message=_body, text=_body) from None + finally: + try: + e.response.close() + except Exception: + pass _text = mask_sensitive_info(_safe_get_response_text(e.response)) raise MaskedHTTPStatusError(e, message=_text, text=_text) from None @@ -414,8 +444,19 @@ def _raise_masked_sync_error(e: httpx.HTTPStatusError, stream: bool) -> None: async def _raise_masked_async_error(e: httpx.HTTPStatusError, stream: bool) -> None: """Raise a MaskedHTTPStatusError for async HTTP handlers.""" if stream: - _body = mask_sensitive_info(await _safe_aread_response(e.response)) - raise MaskedHTTPStatusError(e, message=_body, text=_body) from None + try: + _body = mask_sensitive_info( + await _safe_aread_response( + e.response, + timeout=_STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS, + ) + ) + raise MaskedHTTPStatusError(e, message=_body, text=_body) from None + finally: + try: + await e.response.aclose() + except Exception: + pass _text = mask_sensitive_info(_safe_get_response_text(e.response)) raise MaskedHTTPStatusError(e, message=_text, text=_text) from None diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 86ca662562..d413a24453 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -21,7 +21,9 @@ from litellm._logging import verbose_proxy_logger from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -73,6 +75,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): return data skip_system = effective_skip_system_message_for_guardrail(guardrail_to_apply) + skip_tool = effective_skip_tool_message_for_guardrail(guardrail_to_apply) texts_to_check: List[str] = [] images_to_check: List[str] = [] @@ -91,6 +94,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): text_task_mappings=text_task_mappings, tool_call_task_mappings=tool_call_task_mappings, skip_system_message=skip_system, + skip_tool_message=skip_tool, ) # Step 2: Apply guardrail to all texts and tool calls in batch @@ -102,11 +106,15 @@ class OpenAIChatCompletionsHandler(BaseTranslation): inputs["tool_calls"] = tool_calls_to_check # type: ignore structured_messages = self.get_structured_messages(data) if structured_messages: - inputs["structured_messages"] = ( - openai_messages_without_system(structured_messages) - if skip_system - else structured_messages - ) + if skip_system: + structured_messages = openai_messages_without_system( + structured_messages + ) + if skip_tool: + structured_messages = openai_messages_without_tool( + structured_messages + ) + inputs["structured_messages"] = structured_messages # Pass tools (function definitions) to the guardrail tools = data.get("tools") if tools: @@ -176,13 +184,17 @@ class OpenAIChatCompletionsHandler(BaseTranslation): text_task_mappings: List[Tuple[int, Optional[int]]], tool_call_task_mappings: List[Tuple[int, int]], skip_system_message: bool = False, + skip_tool_message: bool = False, ) -> None: """ Extract text content, images, and tool calls from a message. Override this method to customize text/image/tool call extraction logic. """ - if skip_system_message and str(message.get("role") or "").lower() == "system": + role = str(message.get("role") or "").lower() + if skip_system_message and role == "system": + return + if skip_tool_message and role == "tool": return content = message.get("content", None) diff --git a/litellm/llms/sagemaker/common_utils.py b/litellm/llms/sagemaker/common_utils.py index ad6b24d85a..50c8ee4220 100644 --- a/litellm/llms/sagemaker/common_utils.py +++ b/litellm/llms/sagemaker/common_utils.py @@ -9,7 +9,27 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.utils import GenericStreamingChunk as GChunk from litellm.types.utils import StreamingChatCompletionChunk -_response_stream_shape_cache = None + +def _load_sagemaker_response_stream_shape(): + try: + from botocore.loaders import Loader + from botocore.model import ServiceModel + + loader = Loader() + service_dict = loader.load_service_model("sagemaker-runtime", "service-2") + return ServiceModel(service_dict).shape_for( + "InvokeEndpointWithResponseStreamOutput" + ) + except Exception as e: + verbose_logger.warning( + "litellm: could not pre-load sagemaker-runtime response stream shape " + "— SageMaker event-stream decoding will be unavailable. Error: %s", + e, + ) + return None + + +SAGEMAKER_RESPONSE_STREAM_SHAPE = _load_sagemaker_response_stream_shape() class SagemakerError(BaseLLMException): @@ -187,8 +207,18 @@ class AWSEventStreamDecoder: verbose_logger.error(f"Final error parsing accumulated JSON: {e}") def _parse_message_from_event(self, event) -> Optional[str]: + if SAGEMAKER_RESPONSE_STREAM_SHAPE is None: + raise SagemakerError( + status_code=500, + message=( + "SageMaker event-stream shape could not be loaded from botocore. " + "Ensure botocore is correctly installed." + ), + ) response_dict = event.to_response_dict() - parsed_response = self.parser.parse(response_dict, get_response_stream_shape()) + parsed_response = self.parser.parse( + response_dict, SAGEMAKER_RESPONSE_STREAM_SHAPE + ) if response_dict["status_code"] != 200: raise ValueError(f"Bad response code, expected 200: {response_dict}") @@ -204,20 +234,3 @@ class AWSEventStreamDecoder: return None return chunk.decode() # type: ignore[no-any-return] - - -def get_response_stream_shape(): - global _response_stream_shape_cache - if _response_stream_shape_cache is None: - from botocore.loaders import Loader - from botocore.model import ServiceModel - - loader = Loader() - sagemaker_service_dict = loader.load_service_model( - "sagemaker-runtime", "service-2" - ) - sagemaker_service_model = ServiceModel(sagemaker_service_dict) - _response_stream_shape_cache = sagemaker_service_model.shape_for( - "InvokeEndpointWithResponseStreamOutput" - ) - return _response_stream_shape_cache diff --git a/litellm/main.py b/litellm/main.py index e0d35c2f8d..c3dcdf46f7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1461,14 +1461,14 @@ def completion( # type: ignore # noqa: PLR0915 if eos_token: custom_prompt_dict[model]["eos_token"] = eos_token - if kwargs.get("model_file_id_mapping"): - messages = update_messages_with_model_file_ids( - messages=messages, - model_id=kwargs.get("model_info", {}).get("id", None), - model_file_id_mapping=cast( - Dict[str, Dict[str, str]], kwargs.get("model_file_id_mapping") - ), - ) + messages = update_messages_with_model_file_ids( + messages=messages, + model_id=kwargs.get("model_info", {}).get("id", None), + model_file_id_mapping=cast( + Dict[str, Dict[str, str]], + kwargs.get("model_file_id_mapping") or {}, + ), + ) provider_config: Optional[BaseConfig] = None if custom_llm_provider is not None and custom_llm_provider in [ diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7946e2dcee..76d2d35a53 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -27187,6 +27187,20 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.6-plus": { + "input_cost_per_token": 3.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.95e-06, + "source": "https://openrouter.ai/qwen/qwen3.6-plus", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/qwen/qwen3.5-35b-a3b": { "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", @@ -28874,6 +28888,19 @@ "mode": "chat", "output_cost_per_token": 0.0 }, + "sambanova/MiniMax-M2.7": { + "input_cost_per_token": 3e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "sambanova/DeepSeek-R1": { "input_cost_per_token": 5e-06, "litellm_provider": "sambanova", @@ -34927,6 +34954,48 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.3": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "xai", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.3-latest": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "xai", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", diff --git a/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py b/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py new file mode 100644 index 0000000000..97a16ad3e1 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/auth/token_exchange.py @@ -0,0 +1,196 @@ +""" +OAuth 2.0 Token Exchange (RFC 8693) handler for MCP servers. + +Exchanges a user's incoming JWT (subject_token) for a scoped access token +at an IDP's token exchange endpoint. The exchanged token is then used to +authenticate requests to the upstream MCP server. + +See: https://datatracker.ietf.org/doc/html/rfc8693 +""" + +import asyncio +import hashlib +import weakref +from typing import TYPE_CHECKING, Dict, Tuple + +import httpx + +from litellm._logging import verbose_logger +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.constants import ( + MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE, +) +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.types.llms.custom_http import httpxSpecialProvider + +if TYPE_CHECKING: + from litellm.types.mcp_server.mcp_server_manager import MCPServer + +# RFC 8693 grant type constant +TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + +DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + + +class TokenExchangeHandler: + """Handles OAuth 2.0 Token Exchange (RFC 8693) for MCP servers. + + Caches exchanged tokens keyed by ``hash(subject_token + server_id)`` so + repeated calls with the same user token skip the IDP round-trip. + """ + + def __init__(self) -> None: + self._cache = InMemoryCache( + max_size_in_memory=MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE, + default_ttl=MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + ) + # WeakValueDictionary so locks are GC'd once no coroutine holds a reference, + # preventing unbounded growth with many rotating user tokens. + self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( + weakref.WeakValueDictionary() + ) + + def _get_lock(self, cache_key: str) -> asyncio.Lock: + lock = self._locks.get(cache_key) + if lock is None: + lock = asyncio.Lock() + self._locks[cache_key] = lock + return lock + + @staticmethod + def _cache_key(subject_token: str, server_id: str) -> str: + raw = f"{subject_token}:{server_id}" + return hashlib.sha256(raw.encode()).hexdigest() + + async def exchange_token( + self, + subject_token: str, + server: "MCPServer", + ) -> str: + """Exchange *subject_token* for a scoped access token. + + Returns the exchanged ``access_token`` string (suitable for a + ``Bearer`` header). + + Raises ``ValueError`` on configuration or IDP errors. + """ + cache_key = self._cache_key(subject_token, server.server_id) + + # Fast path + cached = self._cache.get_cache(cache_key) + if cached is not None: + return cached + + # Slow path — one exchange at a time per (user, server) pair + async with self._get_lock(cache_key): + cached = self._cache.get_cache(cache_key) + if cached is not None: + return cached + + token, ttl = await self._do_exchange(subject_token, server) + self._cache.set_cache(cache_key, token, ttl=ttl) + return token + + async def _do_exchange( + self, + subject_token: str, + server: "MCPServer", + ) -> Tuple[str, int]: + """POST to the token exchange endpoint with RFC 8693 parameters. + + Returns ``(access_token, ttl_seconds)``. + """ + endpoint = server.token_exchange_endpoint or server.token_url + if not endpoint: + raise ValueError( + f"MCP server '{server.server_id}' has auth_type=oauth2_token_exchange " + f"but no token_exchange_endpoint or token_url configured" + ) + if not server.client_id or not server.client_secret: + raise ValueError( + f"MCP server '{server.server_id}' has auth_type=oauth2_token_exchange " + f"but missing client_id or client_secret" + ) + + data: Dict[str, str] = { + "grant_type": TOKEN_EXCHANGE_GRANT_TYPE, + "subject_token": subject_token, + "subject_token_type": server.subject_token_type + or DEFAULT_SUBJECT_TOKEN_TYPE, + "client_id": server.client_id, + "client_secret": server.client_secret, + } + if server.audience: + data["audience"] = server.audience + if server.scopes: + data["scope"] = " ".join(server.scopes) + + verbose_logger.debug( + "Exchanging token for MCP server %s at %s (audience=%s)", + server.server_id, + endpoint, + server.audience, + ) + + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) + try: + response = await client.post(endpoint, data=data) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + verbose_logger.debug( + "Token exchange IDP error for MCP server %s (status %d)", + server.server_id, + exc.response.status_code, + ) + raise ValueError( + f"Token exchange for MCP server '{server.server_id}' " + f"failed with status {exc.response.status_code}" + ) from exc + + body = response.json() + if not isinstance(body, dict): + raise ValueError( + f"Token exchange response for MCP server '{server.server_id}' " + f"returned non-object JSON (got {type(body).__name__})" + ) + + access_token = body.get("access_token") + if not access_token: + raise ValueError( + f"Token exchange response for MCP server '{server.server_id}' " + f"missing 'access_token'" + ) + + raw_expires_in = body.get("expires_in") + try: + expires_in = ( + int(raw_expires_in) + if raw_expires_in is not None + else MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL + ) + except (TypeError, ValueError): + expires_in = MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL + + ttl = max( + expires_in - MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + ) + + verbose_logger.info( + "Token exchange succeeded for MCP server %s (expires in %ds)", + server.server_id, + expires_in, + ) + return access_token, ttl + + def invalidate(self, subject_token: str, server_id: str) -> None: + """Remove a cached exchanged token (e.g. after a 401).""" + cache_key = self._cache_key(subject_token, server_id) + self._cache.delete_cache(cache_key) + + +# Module-level singleton +mcp_token_exchange_handler = TokenExchangeHandler() diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 9923c3ce4b..6ad731e711 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -411,6 +411,15 @@ class MCPServerManager: aws_role_name=server_config.get("aws_role_name", None), aws_session_name=server_config.get("aws_session_name", None), instructions=server_config.get("instructions", None), + # Token Exchange (OBO) fields + token_exchange_endpoint=server_config.get( + "token_exchange_endpoint", None + ), + audience=server_config.get("audience", None), + subject_token_type=server_config.get( + "subject_token_type", + "urn:ietf:params:oauth:token-type:access_token", + ), ) self._assign_unique_short_prefix(new_server) self.config_mcp_servers[server_id] = new_server @@ -497,7 +506,8 @@ class MCPServerManager: # Add any static headers from server config. # # Note: `extra_headers` on MCPServer is a List[str] of header names to forward - # from the client request (not available in this OpenAPI tool generation step). + # from each client MCP request; values are applied at call time via + # `_request_extra_headers` in server.py (not baked in here). # `static_headers` is a dict of concrete headers to always send. headers = ( merge_mcp_headers( @@ -765,10 +775,23 @@ class MCPServerManager: aws_role_name=aws_creds.get("aws_role_name"), aws_session_name=aws_creds.get("aws_session_name"), instructions=mcp_server.instructions, + # Token Exchange (OBO) fields — read from credentials JSON blob + token_exchange_endpoint=( + credentials_dict.get("token_exchange_endpoint") + if credentials_dict + else None + ), + audience=(credentials_dict.get("audience") if credentials_dict else None), + subject_token_type=( + credentials_dict.get("subject_token_type") if credentials_dict else None + ) + or "urn:ietf:params:oauth:token-type:access_token", ) return new_server - async def _maybe_register_openapi_tools(self, server: MCPServer): + async def _maybe_register_openapi_tools( + self, server: MCPServer, *, initialize_mapping: bool = True + ): """Register OpenAPI tools if the server has a spec_path configured.""" if server.spec_path: verbose_logger.info( @@ -779,7 +802,8 @@ class MCPServerManager: server=server, base_url=server.url or "", ) - self.initialize_tool_name_to_mcp_server_name_mapping() + if initialize_mapping: + self.initialize_tool_name_to_mcp_server_name_mapping() async def add_server(self, mcp_server: LiteLLM_MCPServerTable): try: @@ -1136,6 +1160,29 @@ class MCPServerManager: ######################################################### # Methods that call the upstream MCP servers ######################################################### + @staticmethod + def _extract_bearer_token( + oauth2_headers: Optional[Dict[str, str]], + raw_headers: Optional[Dict[str, str]], + ) -> Optional[str]: + """Extract the bare Bearer token from oauth2_headers or raw_headers. + + Returns the token string without the ``Bearer `` prefix, or ``None`` + if no Authorization header is found. + """ + auth_value: Optional[str] = None + if oauth2_headers and "Authorization" in oauth2_headers: + auth_value = oauth2_headers["Authorization"] + elif raw_headers: + # raw_headers may have lowercase keys depending on the ASGI server + normalized = {k.lower(): v for k, v in raw_headers.items()} + auth_value = normalized.get("authorization") + if auth_value: + if auth_value.startswith("Bearer "): + return auth_value[len("Bearer ") :] + return auth_value + return None + def _build_stdio_env( self, server: MCPServer, @@ -1169,25 +1216,30 @@ class MCPServerManager: mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, extra_headers: Optional[Dict[str, str]] = None, stdio_env: Optional[Dict[str, str]] = None, + subject_token: Optional[str] = None, ) -> MCPClient: """ Create an MCPClient instance for the given server. Auth resolution (single place for all auth logic): 1. ``mcp_auth_header`` — per-request/per-user override - 2. OAuth2 client_credentials token — auto-fetched and cached - 3. ``server.authentication_token`` — static token from config/DB + 2. OAuth2 Token Exchange (OBO) — exchange user token for scoped token + 3. OAuth2 client_credentials token — auto-fetched and cached + 4. ``server.authentication_token`` — static token from config/DB Args: server: The server configuration. mcp_auth_header: Optional per-request auth override. extra_headers: Additional headers to forward. stdio_env: Environment variables for stdio transport. + subject_token: Optional user JWT for token exchange (OBO) flow. Returns: Configured MCP client instance. """ - auth_value = await resolve_mcp_auth(server, mcp_auth_header) + auth_value = await resolve_mcp_auth( + server, mcp_auth_header, subject_token=subject_token + ) transport = server.transport or MCPTransport.sse @@ -1978,7 +2030,11 @@ class MCPServerManager: _SHORT_PREFIX_MAX_REHASH_ATTEMPTS = 1024 - def _assign_unique_short_prefix(self, server: MCPServer) -> None: + def _assign_unique_short_prefix( + self, + server: MCPServer, + registry: Optional[Dict[str, MCPServer]] = None, + ) -> None: """Resolve and cache a collision-free short tool prefix on ``server``. Called at registration time for every MCP server entering the @@ -2002,7 +2058,8 @@ class MCPServerManager: return used: Dict[str, str] = {} - for other in self.get_registry().values(): + registry_for_collision_check = registry or self.get_registry() + for other in registry_for_collision_check.values(): if other.server_id == server.server_id: continue if other.short_prefix: @@ -2534,9 +2591,12 @@ class MCPServerManager: if server_auth_header is None: server_auth_header = mcp_auth_header - # oauth2 headers + # Extract subject token for OAuth2 Token Exchange (OBO) flow + subject_token: Optional[str] = None extra_headers: Optional[Dict[str, str]] = None - if mcp_server.auth_type == MCPAuth.oauth2: + if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: + subject_token = self._extract_bearer_token(oauth2_headers, raw_headers) + elif mcp_server.auth_type == MCPAuth.oauth2: if mcp_server.has_client_credentials: # For M2M OAuth servers, Authorization must come from token fetch. extra_headers = None @@ -2604,6 +2664,7 @@ class MCPServerManager: mcp_auth_header=server_auth_header, extra_headers=extra_headers, stdio_env=stdio_env, + subject_token=subject_token, ) call_tool_params = MCPCallToolRequestParams( @@ -2916,46 +2977,72 @@ class MCPServerManager: # against the *full* set so dedup is deterministic regardless of # iteration order. for server in db_mcp_servers: - existing_server = previous_registry.get(server.server_id) + try: + existing_server = previous_registry.get(server.server_id) - if ( - existing_server is not None - and existing_server.updated_at is not None - and server.updated_at is not None - and existing_server.updated_at == server.updated_at - ): - # Re-use existing server instance to avoid re-running build_mcp_server_from_table() - # which can perform network discovery for OAuth2 servers. - new_registry[server.server_id] = existing_server - continue + if ( + existing_server is not None + and existing_server.updated_at is not None + and server.updated_at is not None + and existing_server.updated_at == server.updated_at + ): + # Re-use existing server instance to avoid re-running build_mcp_server_from_table() + # which can perform network discovery for OAuth2 servers. + new_registry[server.server_id] = existing_server + continue - _warn_on_server_name_fields( - server_id=server.server_id, - alias=getattr(server, "alias", None), - server_name=getattr(server, "server_name", None), - ) - verbose_logger.debug( - f"Building server from DB: {server.server_id} ({server.server_name})" - ) - new_server = await self.build_mcp_server_from_table(server) - # Carry the cached short_prefix from the previous registry entry - # (if any) so the prefix is stable across reloads. - if existing_server is not None and existing_server.short_prefix: - new_server.short_prefix = existing_server.short_prefix - new_registry[server.server_id] = new_server + _warn_on_server_name_fields( + server_id=server.server_id, + alias=getattr(server, "alias", None), + server_name=getattr(server, "server_name", None), + ) + verbose_logger.debug( + f"Building server from DB: {server.server_id} ({server.server_name})" + ) + new_server = await self.build_mcp_server_from_table(server) + # Carry the cached short_prefix from the previous registry entry + # (if any) so the prefix is stable across reloads. + if existing_server is not None and existing_server.short_prefix: + new_server.short_prefix = existing_server.short_prefix + new_registry[server.server_id] = new_server + except Exception as e: + verbose_logger.exception( + "Skipping MCP server %s (%s) during DB reload: %s", + server.server_id, + getattr(server, "alias", None), + e, + ) - # Swap in the new registry first so _assign_unique_short_prefix - # sees the complete set when checking for collisions. - self.registry = new_registry - for new_server in new_registry.values(): - self._assign_unique_short_prefix(new_server) - # Register OpenAPI tools *after* the final short prefix is assigned - # so the tools are stored in the global registry under the same - # prefix that lookups will use. - await self._maybe_register_openapi_tools(new_server) + # Assign short prefixes against the full candidate set without + # publishing the staged registry to concurrent callers. + registered_registry: Dict[str, MCPServer] = {} + registered_openapi_tools = False + for server_id, new_server in new_registry.items(): + try: + self._assign_unique_short_prefix(new_server, registry=new_registry) + # Register OpenAPI tools *after* the final short prefix is assigned + # so the tools are stored in the global registry under the same + # prefix that lookups will use. + await self._maybe_register_openapi_tools( + new_server, initialize_mapping=False + ) + registered_registry[server_id] = new_server + if new_server.spec_path: + registered_openapi_tools = True + except Exception as e: + verbose_logger.exception( + "Skipping MCP server %s (%s) during DB reload: %s", + new_server.server_id, + getattr(new_server, "alias", None), + e, + ) + + self.registry = registered_registry + if registered_openapi_tools: + self.initialize_tool_name_to_mcp_server_name_mapping() verbose_logger.debug( - "MCP registry refreshed (%s servers in registry)", len(new_registry) + "MCP registry refreshed (%s servers in registry)", len(registered_registry) ) def get_mcp_servers_from_ids(self, server_ids: List[str]) -> List[MCPServer]: diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index 476e215666..92ef57d8cd 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -26,6 +26,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, ) +from litellm.proxy._experimental.mcp_server.auth import token_exchange from litellm.types.llms.custom_http import httpxSpecialProvider if TYPE_CHECKING: @@ -50,12 +51,23 @@ class MCPOAuth2TokenCache(InMemoryCache): def _get_lock(self, server_id: str) -> asyncio.Lock: return self._locks.setdefault(server_id, asyncio.Lock()) - async def async_get_token(self, server: "MCPServer") -> Optional[str]: + @staticmethod + def _has_client_credentials_config(server: "MCPServer") -> bool: + return bool(server.client_id and server.client_secret and server.token_url) + + async def async_get_token( + self, + server: "MCPServer", + *, + require_client_credentials_flow: bool = True, + ) -> Optional[str]: """Return a valid access token, fetching or refreshing as needed. Returns ``None`` when the server lacks client credentials config. """ - if not server.has_client_credentials: + if require_client_credentials_flow and not server.has_client_credentials: + return None + if not self._has_client_credentials_config(server): return None server_id = server.server_id @@ -263,16 +275,38 @@ mcp_per_user_token_cache = MCPPerUserTokenCache() async def resolve_mcp_auth( server: "MCPServer", mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, + subject_token: Optional[str] = None, ) -> Optional[Union[str, Dict[str, str]]]: """Resolve the auth value for an MCP server. Priority: 1. ``mcp_auth_header`` — per-request/per-user override - 2. OAuth2 client_credentials token — auto-fetched and cached - 3. ``server.authentication_token`` — static token from config/DB + 2. OAuth2 Token Exchange (OBO / RFC 8693) — exchange user token for scoped token + 3. OAuth2 client_credentials token — auto-fetched and cached + 4. ``server.authentication_token`` — static token from config/DB """ if mcp_auth_header: return mcp_auth_header + if server.has_token_exchange_config: + if subject_token: + return await token_exchange.mcp_token_exchange_handler.exchange_token( + subject_token, server + ) + # No subject_token — fall back to client_credentials using the same client + # credentials and token_url so M2M scenarios still work. + if server.client_id and server.client_secret and server.token_url: + return await mcp_oauth2_token_cache.async_get_token( + server, + require_client_credentials_flow=False, + ) + # OBO configured but no subject_token and missing client credentials — warn + # rather than silently proceeding unauthenticated. + verbose_logger.warning( + "MCP server '%s' is configured for token exchange (OBO) but no subject_token " + "was provided and client credentials (client_id/client_secret/token_url) are " + "incomplete. The request will proceed without authentication.", + server.server_id, + ) if server.has_client_credentials: return await mcp_oauth2_token_cache.async_get_token(server) return server.authentication_token diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 718435cce6..271517bb1e 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -55,6 +55,13 @@ _request_auth_header: contextvars.ContextVar[Optional[str]] = contextvars.Contex "_request_auth_header", default=None ) +# Per-request extra headers forwarded from the client request. +# Populated from MCPServer.extra_headers names matched against raw request +# headers in server.py before dispatching to a local/OpenAPI tool handler. +_request_extra_headers: contextvars.ContextVar[Optional[Dict[str, str]]] = ( + contextvars.ContextVar("_request_extra_headers", default=None) +) + def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str: """Ensure path params cannot introduce directory traversal.""" @@ -297,6 +304,46 @@ def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]: } +def _merge_openapi_tool_request_headers( + static_headers: Dict[str, str] +) -> Dict[str, str]: + """Merge static closure headers with per-request ContextVar overrides. + + Precedence (highest to lowest): + 1. ``_request_auth_header`` — BYOK override of ``Authorization`` + 2. ``static_headers`` — operator-configured headers baked into the + tool closure at registration time + 3. ``_request_extra_headers`` — per-request headers forwarded from + the MCP caller (allowlisted by ``MCPServer.extra_headers``) + + This matches the existing MCP invariant in + :func:`litellm.proxy._experimental.mcp_server.utils.merge_mcp_headers` + and the managed MCP path, where ``static_headers`` always wins over + caller-forwarded headers. Keeping the same precedence here prevents an + authenticated caller from overriding an operator-configured value + (e.g. a tenant id or upstream API key) by sending the same header name. + + Header names are compared case-insensitively so different casing cannot + bypass the precedence rules. + """ + request_extra = _request_extra_headers.get() or {} + static = static_headers or {} + + static_lower_names = {k.lower() for k in static} + effective_headers: Dict[str, str] = { + k: v for k, v in request_extra.items() if k.lower() not in static_lower_names + } + effective_headers.update(static) + + override_auth = _request_auth_header.get() + if override_auth: + for existing in [k for k in effective_headers if k.lower() == "authorization"]: + del effective_headers[existing] + effective_headers["Authorization"] = override_auth + + return effective_headers + + def create_tool_function( path: str, method: str, @@ -334,14 +381,7 @@ def create_tool_function( The function safely handles parameter names that aren't valid Python identifiers by using **kwargs instead of named parameters. """ - # Allow per-request auth override (e.g. BYOK credential set via ContextVar). - # The ContextVar holds the full Authorization header value, including the - # correct prefix (Bearer / ApiKey / Basic) formatted by the caller in - # server.py based on the server's configured auth_type. - effective_headers = dict(headers) - override_auth = _request_auth_header.get() - if override_auth: - effective_headers["Authorization"] = override_auth + effective_headers = _merge_openapi_tool_request_headers(headers) # Build URL from base_url and path url = base_url + path diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 54d9bbe6e2..276a6e8a3b 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -158,6 +158,7 @@ if MCP_AVAILABLE: ) from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( _request_auth_header, + _request_extra_headers, ) from litellm.proxy._experimental.mcp_server.sse_transport import SseServerTransport from litellm.proxy._experimental.mcp_server.tool_registry import ( @@ -2195,11 +2196,40 @@ if MCP_AVAILABLE: auth_header_value = f"Basic {mcp_auth_header}" else: auth_header_value = f"Bearer {mcp_auth_header}" + + # Forward named client headers to OpenAPI tool upstream requests. + # MCPServer.extra_headers lists header names to copy from raw_headers. + # OAuth2 M2M: never take Authorization from the caller (matches + # _prepare_mcp_server_headers for managed MCP). + forwarded_headers: Optional[Dict[str, str]] = None + if mcp_server and mcp_server.extra_headers and raw_headers: + normalized_raw = { + str(k).lower(): v + for k, v in raw_headers.items() + if isinstance(k, str) + } + skip_caller_authorization = bool(mcp_server.has_client_credentials) + for header_name in mcp_server.extra_headers: + if not isinstance(header_name, str): + continue + if ( + skip_caller_authorization + and header_name.lower() == "authorization" + ): + continue + value = normalized_raw.get(header_name.lower()) + if value is not None: + if forwarded_headers is None: + forwarded_headers = {} + forwarded_headers[header_name] = value + _auth_token = _request_auth_header.set(auth_header_value) + _extra_token = _request_extra_headers.set(forwarded_headers) try: local_content = await _handle_local_mcp_tool(name, arguments) finally: _request_auth_header.reset(_auth_token) + _request_extra_headers.reset(_extra_token) response = CallToolResult(content=cast(Any, local_content), isError=False) # Try managed MCP server tool (pass the full prefixed name) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7a049dcc5d..795d2a6161 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -353,8 +353,10 @@ class LiteLLMRoutes(enum.Enum): # realtime "/realtime", "/v1/realtime", + "/openai/v1/realtime", "/realtime?{model}", "/v1/realtime?{model}", + "/openai/v1/realtime?{model}", # responses API "/responses", "/v1/responses", @@ -707,6 +709,8 @@ class LiteLLMRoutes(enum.Enum): # Project read routes - endpoint scopes results to caller's teams (non-admin) "/project/list", "/project/info", + # Endpoint enforces proxy-admin vs team-admin model access itself. + "/health/test_connection", # Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges "/invitation/new", "/invitation/delete", @@ -3356,6 +3360,19 @@ class AllCallbacks(LiteLLMPydanticObjectBase): ], ) + azure_sentinel: CallbackOnUI = CallbackOnUI( + litellm_callback_name="azure_sentinel", + ui_callback_name="Azure Sentinel", + litellm_callback_params=[ + "AZURE_SENTINEL_DCR_IMMUTABLE_ID", + "AZURE_SENTINEL_ENDPOINT", + "AZURE_SENTINEL_TENANT_ID", + "AZURE_SENTINEL_CLIENT_ID", + "AZURE_SENTINEL_CLIENT_SECRET", + "AZURE_SENTINEL_STREAM_NAME", + ], + ) + openmeter: CallbackOnUI = CallbackOnUI( litellm_callback_name="openmeter", ui_callback_name="OpenMeter", diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1b6bd3aff2..0b30999aa2 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2849,7 +2849,7 @@ def _can_object_call_model( object_type=object_type ), param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) @@ -3082,7 +3082,7 @@ async def can_user_call_model( message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}", type=ProxyErrorTypes.key_model_access_denied, param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) return _can_object_call_model( @@ -3516,7 +3516,6 @@ async def _check_team_member_budget( if ( team_object is not None and team_object.team_id is not None - and user_object is not None and valid_token is not None and valid_token.user_id is not None ): @@ -3619,13 +3618,14 @@ async def _check_team_member_model_access( llm_router=llm_router, models=member_allowed_models, object_type="team", + team_id=team_object.team_id, ) except ProxyException: raise ProxyException( message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}", type=ProxyErrorTypes.team_model_access_denied, param="model", - code=status.HTTP_401_UNAUTHORIZED, + code=status.HTTP_403_FORBIDDEN, ) diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 5ded8136ef..431db4254e 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -123,7 +123,7 @@ class UserAPIKeyAuthExceptionHandler: message=e.message, type=ProxyErrorTypes.budget_exceeded, param=None, - code=400, + code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS), ) if isinstance(e, HTTPException): raise ProxyException( diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 9d3c06e641..4778549bef 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1107,7 +1107,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=abbreviate_api_key(api_key=api_key), ) valid_token = update_valid_token_with_end_user_params( @@ -1432,7 +1432,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=abbreviate_api_key(api_key=api_key), ) @@ -2417,7 +2417,7 @@ async def _run_post_custom_auth_checks( raise ProxyException( message=f"Authentication Error - Expired Key. Key Expiry time {expiry_time} and current time {current_time}", type=ProxyErrorTypes.expired_key, - code=400, + code=status.HTTP_401_UNAUTHORIZED, param=( abbreviate_api_key(api_key=valid_token.token) if valid_token.token diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index baa0853700..038d2d8127 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1512,7 +1512,7 @@ class ProxyBaseLLMRequestProcessing: status_code=result.status_code, headers=HttpPassThroughEndpointHelpers.get_response_headers( headers=result.headers, - custom_headers=None, + custom_headers=dict(fastapi_response.headers), ), ) diff --git a/litellm/proxy/common_utils/html_forms/jwt_display_template.py b/litellm/proxy/common_utils/html_forms/jwt_display_template.py index 03dff78dba..ea65fd0e28 100644 --- a/litellm/proxy/common_utils/html_forms/jwt_display_template.py +++ b/litellm/proxy/common_utils/html_forms/jwt_display_template.py @@ -14,7 +14,7 @@ jwt_display_template = """ padding: 20px; display: flex; justify-content: center; - align-items: center; + align-items: flex-start; min-height: 100vh; color: #333; } @@ -27,18 +27,18 @@ jwt_display_template = """ width: 800px; max-width: 100%; } - + .logo-container { text-align: center; margin-bottom: 30px; } - + .logo { font-size: 24px; font-weight: 600; color: #1e293b; } - + h2 { margin: 0 0 10px; color: #1e293b; @@ -46,7 +46,14 @@ jwt_display_template = """ font-weight: 600; text-align: center; } - + + h3 { + margin: 0 0 12px; + color: #1e293b; + font-size: 18px; + font-weight: 600; + } + .subtitle { color: #64748b; margin: 0 0 20px; @@ -58,15 +65,15 @@ jwt_display_template = """ background-color: #f1f5f9; border-radius: 6px; padding: 20px; - margin-bottom: 30px; + margin-bottom: 20px; border-left: 4px solid #2563eb; } - + .success-box { background-color: #f0fdf4; border-radius: 6px; padding: 20px; - margin-bottom: 30px; + margin-bottom: 20px; border-left: 4px solid #16a34a; } @@ -78,7 +85,7 @@ jwt_display_template = """ font-weight: 600; font-size: 16px; } - + .success-header { display: flex; align-items: center; @@ -87,46 +94,53 @@ jwt_display_template = """ font-weight: 600; font-size: 16px; } - + .info-header svg, .success-header svg { margin-right: 8px; } - + .data-container { margin-top: 20px; } - + .data-row { display: flex; border-bottom: 1px solid #e2e8f0; padding: 12px 0; } - + .data-row:last-child { border-bottom: none; } - + .data-label { font-weight: 500; color: #334155; - width: 180px; + width: 220px; flex-shrink: 0; } - + .data-value { color: #475569; word-break: break-all; } - + + .empty-note { + color: #64748b; + font-style: italic; + margin: 0; + font-size: 14px; + } + .jwt-container { background-color: #f8fafc; border-radius: 6px; padding: 15px; - margin-top: 20px; + margin-top: 12px; overflow-x: auto; border: 1px solid #e2e8f0; } - + .jwt-text { font-family: monospace; white-space: pre-wrap; @@ -134,7 +148,7 @@ jwt_display_template = """ margin: 0; color: #334155; } - + .back-button { display: inline-block; background-color: #6466E9; @@ -146,18 +160,18 @@ jwt_display_template = """ margin-top: 20px; text-align: center; } - + .back-button:hover { background-color: #4138C2; text-decoration: none; } - + .buttons { display: flex; gap: 10px; - margin-top: 20px; + margin-top: 12px; } - + .copy-button { background-color: #e2e8f0; color: #334155; @@ -169,11 +183,11 @@ jwt_display_template = """ display: flex; align-items: center; } - + .copy-button:hover { background-color: #cbd5e1; } - + .copy-button svg { margin-right: 6px; } @@ -188,7 +202,7 @@ jwt_display_template = """

SSO Debug Information

Results from the SSO authentication process.

- +
@@ -199,11 +213,7 @@ jwt_display_template = """

The SSO authentication completed successfully. Below is the information returned by the provider.

- -
- -
- +
@@ -211,22 +221,62 @@ jwt_display_template = """ - JSON Representation + Parsed by Proxy
+

Fields the proxy extracted into its internal user model.

+
+ +
+
+ +
+
+ + + + + + Raw Claims (userinfo) +
+

Complete set of claims returned by the IdP's userinfo endpoint.

-
Loading...
+
Loading...
-
- + +
+
+ + + + + + Access Token Claims +
+

Decoded payload of the access token JWT (when the IdP issues one).

+
+
Loading...
+
+
+ +
+
+ Try Another SSO Login @@ -234,39 +284,58 @@ jwt_display_template = """ diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 0928ce914d..d4c5d76ac1 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -2,7 +2,7 @@ import asyncio import json import time from datetime import datetime, timezone -from typing import Any, List, Literal, Optional, Union +from typing import Any, Callable, List, Literal, Optional, Union import litellm from litellm._logging import verbose_proxy_logger @@ -83,93 +83,97 @@ class ResetBudgetJob: "Failed to reset spend counter %s: %s", counter_key, e ) + async def _cascade_reset_spend_for_budget_link( + self, + budgets_to_reset: List[LiteLLM_BudgetTableFull], + table: Any, + counter_key_fn: Callable[[Any], str], + log_subject: str, + extra_where: Optional[dict] = None, + ): + """ + Generic cascade: zero spend on rows whose budget_id is in the reset set. + """ + budget_ids = [b.budget_id for b in budgets_to_reset if b.budget_id is not None] + if not budget_ids: + return + + where: dict = {"budget_id": {"in": budget_ids}} + if extra_where: + where.update(extra_where) + + try: + rows = await table.find_many(where=where) + except Exception as e: + rows = [] + verbose_proxy_logger.warning( + "Failed to fetch %s for counter invalidation: %s", log_subject, e + ) + + update_result = await table.update_many(where=where, data={"spend": 0}) + + for row in rows: + await self._invalidate_spend_counter(counter_key_fn(row)) + + return update_result + async def reset_budget_for_litellm_team_members( self, budgets_to_reset: List[LiteLLM_BudgetTableFull] ): """ Resets the budget for all LiteLLM Team Members if their budget has expired """ - budget_ids = [ - budget.budget_id - for budget in budgets_to_reset - if budget.budget_id is not None - ] - - try: - memberships = await self.prisma_client.db.litellm_teammembership.find_many( - where={"budget_id": {"in": budget_ids}} - ) - except Exception as e: - memberships = [] - verbose_proxy_logger.warning( - "Failed to fetch team memberships for counter invalidation: %s", e - ) - - update_result = await self.prisma_client.db.litellm_teammembership.update_many( - where={"budget_id": {"in": budget_ids}}, - data={ - "spend": 0, - }, + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_teammembership, + counter_key_fn=lambda m: f"spend:team_member:{m.user_id}:{m.team_id}", + log_subject="team memberships", ) - for m in memberships: - await self._invalidate_spend_counter( - f"spend:team_member:{m.user_id}:{m.team_id}" - ) - - return update_result - async def reset_budget_for_keys_linked_to_budgets( self, budgets_to_reset: List[LiteLLM_BudgetTableFull] ): """ Resets the spend for keys linked to budget tiers that are being reset. - This handles keys that have budget_id but no budget_duration set on the key - itself. Keys with budget_id rely on their linked budget tier's reset schedule - rather than having their own budget_duration. - - Keys that have their own budget_duration are already handled by - reset_budget_for_litellm_keys() and are excluded here to avoid - double-resetting. + Excludes keys with their own budget_duration; those are reset by + reset_budget_for_litellm_keys() to avoid double-resetting. """ - budget_ids = [ - budget.budget_id - for budget in budgets_to_reset - if budget.budget_id is not None - ] - if not budget_ids: - return - - where_clause: dict = { - "budget_id": {"in": budget_ids}, - "budget_duration": None, # only keys without their own reset schedule - "spend": {"gt": 0}, # only reset keys that have accumulated spend - } - - try: - keys = await self.prisma_client.db.litellm_verificationtoken.find_many( - where=where_clause - ) - except Exception as e: - keys = [] - verbose_proxy_logger.warning( - "Failed to fetch keys for counter invalidation: %s", e - ) - - update_result = ( - await self.prisma_client.db.litellm_verificationtoken.update_many( - where=where_clause, - data={ - "spend": 0, - }, - ) + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_verificationtoken, + counter_key_fn=lambda k: f"spend:key:{k.token}", + log_subject="keys", + extra_where={"budget_duration": None, "spend": {"gt": 0}}, ) - for k in keys: - await self._invalidate_spend_counter(f"spend:key:{k.token}") + async def reset_budget_for_orgs_linked_to_budgets( + self, budgets_to_reset: List[LiteLLM_BudgetTableFull] + ): + """ + Resets the spend for orgs linked to budget tiers that are being reset. + """ + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_organizationtable, + counter_key_fn=lambda o: f"spend:org:{o.organization_id}", + log_subject="orgs", + extra_where={"spend": {"gt": 0}}, + ) - return update_result + async def reset_budget_for_tags_linked_to_budgets( + self, budgets_to_reset: List[LiteLLM_BudgetTableFull] + ): + """ + Resets the spend for tags linked to budget tiers that are being reset. + """ + return await self._cascade_reset_spend_for_budget_link( + budgets_to_reset=budgets_to_reset, + table=self.prisma_client.db.litellm_tagtable, + counter_key_fn=lambda t: f"spend:tag:{t.tag_name}", + log_subject="tags", + extra_where={"spend": {"gt": 0}}, + ) async def reset_budget_for_litellm_budget_table(self): """ @@ -237,6 +241,14 @@ class ResetBudgetJob: budgets_to_reset=budgets_to_reset ) + await self.reset_budget_for_orgs_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + + await self.reset_budget_for_tags_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + if endusers_to_reset is not None and len(endusers_to_reset) > 0: for enduser in endusers_to_reset: try: diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index bc9efb52b0..9475779cfd 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -5,8 +5,10 @@ from typing import Optional from litellm._logging import verbose_proxy_logger from litellm.caching import RedisCache from litellm.constants import ( + SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS, SPEND_LOG_CLEANUP_BATCH_SIZE, SPEND_LOG_CLEANUP_JOB_NAME, + SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES, SPEND_LOG_RUN_LOOPS, ) from litellm.litellm_core_utils.duration_parser import duration_in_seconds @@ -74,6 +76,7 @@ class SpendLogCleanup: """ total_deleted = 0 run_count = 0 + consecutive_failures = 0 while True: if run_count > SPEND_LOG_RUN_LOOPS: verbose_proxy_logger.info( @@ -82,18 +85,50 @@ class SpendLogCleanup: break # Step 1: Find logs and delete them in one go without fetching to application # Delete in batches, limited by self.batch_size - deleted_result = await prisma_client.db.execute_raw( - """ - DELETE FROM "LiteLLM_SpendLogs" - WHERE "request_id" IN ( - SELECT "request_id" FROM "LiteLLM_SpendLogs" - WHERE "startTime" < $1::timestamptz - LIMIT $2 + try: + deleted_result = await prisma_client.db.execute_raw( + """ + DELETE FROM "LiteLLM_SpendLogs" + WHERE "request_id" IN ( + SELECT "request_id" FROM "LiteLLM_SpendLogs" + WHERE "startTime" < $1::timestamptz + LIMIT $2 + ) + """, + cutoff_date, + self.batch_size, ) - """, - cutoff_date, - self.batch_size, - ) + except Exception as batch_exc: + # A single batch failure (e.g. Prisma/DB timeout) must not abort + # the whole run — subsequent batches may still succeed. + consecutive_failures += 1 + verbose_proxy_logger.exception( + "Spend log cleanup batch failed " + "(run_count=%d, consecutive_failures=%d, batch_size=%d, " + "cutoff=%s, total_deleted_so_far=%d): %s: %s", + run_count, + consecutive_failures, + self.batch_size, + cutoff_date.isoformat(), + total_deleted, + type(batch_exc).__name__, + batch_exc, + ) + if ( + consecutive_failures + >= SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES + ): + verbose_proxy_logger.error( + "Aborting spend log cleanup after %d consecutive batch " + "failures; total deleted before abort: %d", + consecutive_failures, + total_deleted, + ) + break + await asyncio.sleep(SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS) + continue + + consecutive_failures = 0 deleted_count = 0 if isinstance(deleted_result, int): @@ -168,7 +203,13 @@ class SpendLogCleanup: verbose_proxy_logger.info(f"Deleted {total_deleted} logs") except Exception as e: - verbose_proxy_logger.error(f"Error during cleanup: {str(e)}") + # .exception() captures the traceback; str(e) alone on a Prisma/DB + # timeout is often empty and gives operators no signal to diagnose. + verbose_proxy_logger.exception( + "Error during spend log cleanup: %s: %s", + type(e).__name__, + e, + ) return # Return after error handling finally: # Only release the lock if it was actually acquired diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index d112e22230..af5a58802b 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -10,13 +10,64 @@ import subprocess import time import urllib import urllib.parse +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Optional, Union +from typing import Any, Dict, Optional, Union from litellm._logging import verbose_proxy_logger from litellm.secret_managers.main import str_to_bool +@dataclass(frozen=True) +class IAMEndpoint: + """Static parts of an RDS IAM-authenticated Postgres connection. + + The IAM token rotates every ~15 minutes; everything else (host, port, user, + database name, schema) stays fixed. We capture the static fields once so + refresh just regenerates the token and reassembles the URL. + """ + + host: str + port: str + user: str + name: str + schema: Optional[str] = None + + def build_url(self, token: str) -> str: + url = f"postgresql://{self.user}:{token}@{self.host}:{self.port}/{self.name}" + if self.schema: + url += f"?schema={self.schema}" + return url + + +def parse_iam_endpoint_from_url(url: str) -> IAMEndpoint: + """Parse an IAMEndpoint from a Postgres URL. + + Used so a reader URL can drive its own IAM refresh without requiring + callers to set parallel DATABASE_HOST_READ_REPLICA / etc. env vars. + """ + parsed = urllib.parse.urlparse(url) + if not parsed.hostname or not parsed.username: + raise ValueError("Cannot parse IAM endpoint from URL: missing host or username") + name = (parsed.path or "/").lstrip("/") + if not name: + raise ValueError("Cannot parse IAM endpoint from URL: missing database name") + port = str(parsed.port) if parsed.port else "5432" + schema: Optional[str] = None + if parsed.query: + qs = urllib.parse.parse_qs(parsed.query) + schema_vals = qs.get("schema") + if schema_vals: + schema = schema_vals[0] + return IAMEndpoint( + host=parsed.hostname, + port=port, + user=parsed.username, + name=name, + schema=schema, + ) + + class PrismaWrapper: """ Wrapper around Prisma client that handles RDS IAM token authentication. @@ -37,10 +88,33 @@ class PrismaWrapper: # Fallback refresh interval if token parsing fails (10 minutes) FALLBACK_REFRESH_INTERVAL_SECONDS = 600 - def __init__(self, original_prisma: Any, iam_token_db_auth: bool): + def __init__( + self, + original_prisma: Any, + iam_token_db_auth: bool, + *, + db_url_env_var: str = "DATABASE_URL", + iam_endpoint: Optional[IAMEndpoint] = None, + recreate_uses_datasource: bool = False, + log_prefix: str = "", + ): self._original_prisma = original_prisma self.iam_token_db_auth = iam_token_db_auth + # Per-connection knobs so the same wrapper can be used for the writer + # (defaults: DATABASE_URL env, IAM endpoint from DATABASE_HOST/etc., + # recreate via env reload) or for a reader (DATABASE_URL_READ_REPLICA + # env, IAM endpoint parsed from that URL, recreate via datasource + # override since Prisma only auto-reads DATABASE_URL). + self._db_url_env_var = db_url_env_var + self._iam_endpoint = iam_endpoint + self._recreate_uses_datasource = recreate_uses_datasource + # Tag every log line emitted by this wrapper instance so writer and + # reader can be told apart in interleaved output (e.g. "[writer] RDS + # IAM token refresh scheduled in 720 seconds"). Empty string (default) + # keeps backward-compatible logs for the single-DB case. + self._log_prefix = f"{log_prefix} " if log_prefix else "" + # Background token refresh task management self._token_refresh_task: Optional[asyncio.Task] = None self._reconnection_lock = asyncio.Lock() @@ -157,7 +231,7 @@ class PrismaWrapper: Returns 0 if token should be refreshed immediately. Returns FALLBACK_REFRESH_INTERVAL_SECONDS if parsing fails. """ - db_url = os.getenv("DATABASE_URL") + db_url = os.getenv(self._db_url_env_var) token = self._extract_token_from_db_url(db_url) expiration_time = self._parse_token_expiration(token) @@ -199,12 +273,30 @@ class PrismaWrapper: return datetime.utcnow() > expiration_time def get_rds_iam_token(self) -> Optional[str]: - """Generate a new RDS IAM token and update DATABASE_URL.""" - if self.iam_token_db_auth: - from litellm.proxy.auth.rds_iam_token import generate_iam_auth_token + """Generate a new RDS IAM token and update the configured DB URL env var. + When the wrapper was constructed with an explicit `iam_endpoint` + (typical for a reader wrapper whose host/port/user came from a parsed + URL), use that. Otherwise fall back to the legacy DATABASE_HOST/PORT/ + USER/NAME/SCHEMA env vars (writer behavior). + """ + if not self.iam_token_db_auth: + return None + + from litellm.proxy.auth.rds_iam_token import generate_iam_auth_token + + if self._iam_endpoint is not None: + endpoint = self._iam_endpoint + token = generate_iam_auth_token( + db_host=endpoint.host, db_port=endpoint.port, db_user=endpoint.user + ) + _db_url = endpoint.build_url(token) + else: db_host = os.getenv("DATABASE_HOST") - db_port = os.getenv("DATABASE_PORT") + # Default to the Postgres standard port; passing None to + # `generate_iam_auth_token` makes botocore embed the literal + # string "None" in the presigned URL, which then fails to parse. + db_port = os.getenv("DATABASE_PORT", "5432") db_user = os.getenv("DATABASE_USER") db_name = os.getenv("DATABASE_NAME") db_schema = os.getenv("DATABASE_SCHEMA") @@ -217,9 +309,8 @@ class PrismaWrapper: if db_schema: _db_url += f"?schema={db_schema}" - os.environ["DATABASE_URL"] = _db_url - return _db_url - return None + os.environ[self._db_url_env_var] = _db_url + return _db_url async def recreate_prisma_client( self, new_db_url: str, http_client: Optional[Any] = None @@ -231,6 +322,11 @@ class PrismaWrapper: synchronous `subprocess.Popen.wait()` that can freeze the asyncio event loop for 30-120+ seconds when the engine is stuck on TCP close, breaking `/health/liveliness` and causing Kubernetes pod restarts. + + The writer wrapper relies on Prisma re-reading `DATABASE_URL` from env; + the reader wrapper opts into `recreate_uses_datasource=True` so the + new URL is passed explicitly via `datasource={"url": ...}` (Prisma + does not auto-read alternate env vars like DATABASE_URL_READ_REPLICA). """ from prisma import Prisma # type: ignore @@ -238,10 +334,12 @@ class PrismaWrapper: if old_engine_pid > 0: await self._kill_engine_process(old_engine_pid) + kwargs: Dict[str, Any] = {} if http_client is not None: - self._original_prisma = Prisma(http=http_client) - else: - self._original_prisma = Prisma() + kwargs["http"] = http_client + if self._recreate_uses_datasource: + kwargs["datasource"] = {"url": new_db_url} + self._original_prisma = Prisma(**kwargs) await self._original_prisma.connect() @@ -265,7 +363,8 @@ class PrismaWrapper: self._token_refresh_task = asyncio.create_task(self._token_refresh_loop()) verbose_proxy_logger.info( - "Started RDS IAM token proactive refresh background task" + "%sStarted RDS IAM token proactive refresh background task", + self._log_prefix, ) async def stop_token_refresh_task(self) -> None: @@ -283,7 +382,9 @@ class PrismaWrapper: except asyncio.CancelledError: pass self._token_refresh_task = None - verbose_proxy_logger.info("Stopped RDS IAM token refresh background task") + verbose_proxy_logger.info( + "%sStopped RDS IAM token refresh background task", self._log_prefix + ) async def _token_refresh_loop(self) -> None: """ @@ -294,7 +395,7 @@ class PrismaWrapper: This is more efficient than polling, requiring only 1 wake-up per token cycle. """ verbose_proxy_logger.info( - f"RDS IAM token refresh loop started. " + f"{self._log_prefix}RDS IAM token refresh loop started. " f"Tokens will be refreshed {self.TOKEN_REFRESH_BUFFER_SECONDS}s before expiration." ) @@ -305,21 +406,25 @@ class PrismaWrapper: if sleep_seconds > 0: verbose_proxy_logger.info( - f"RDS IAM token refresh scheduled in {sleep_seconds:.0f} seconds " - f"({sleep_seconds / 60:.1f} minutes)" + f"{self._log_prefix}RDS IAM token refresh scheduled in " + f"{sleep_seconds:.0f} seconds ({sleep_seconds / 60:.1f} minutes)" ) await asyncio.sleep(sleep_seconds) # Refresh the token - verbose_proxy_logger.info("Proactively refreshing RDS IAM token...") + verbose_proxy_logger.info( + "%sProactively refreshing RDS IAM token...", self._log_prefix + ) await self._safe_refresh_token() except asyncio.CancelledError: - verbose_proxy_logger.info("RDS IAM token refresh loop cancelled") + verbose_proxy_logger.info( + "%sRDS IAM token refresh loop cancelled", self._log_prefix + ) break except Exception as e: verbose_proxy_logger.error( - f"Error in RDS IAM token refresh loop: {e}. " + f"{self._log_prefix}Error in RDS IAM token refresh loop: {e}. " f"Retrying in {self.FALLBACK_REFRESH_INTERVAL_SECONDS}s..." ) # On error, wait before retrying to avoid tight error loops @@ -341,65 +446,75 @@ class PrismaWrapper: await self.recreate_prisma_client(new_db_url) self._last_refresh_time = datetime.utcnow() verbose_proxy_logger.info( - "RDS IAM token refreshed successfully. New token valid for ~15 minutes." + "%sRDS IAM token refreshed successfully. New token valid for ~15 minutes.", + self._log_prefix, ) else: verbose_proxy_logger.error( - "Failed to generate new RDS IAM token during proactive refresh" + "%sFailed to generate new RDS IAM token during proactive refresh", + self._log_prefix, ) def __getattr__(self, name: str): """ Proxy attribute access to the underlying Prisma client. - If IAM token auth is enabled and the token is expired, this method - provides a synchronous fallback to refresh the token. However, this - should rarely be needed since the background task proactively refreshes - tokens before they expire. + If IAM token auth is enabled and the token is found expired here, the + proactive refresh task has missed its window. Behavior depends on + whether we're called from inside a running event loop: - FIXED: Now properly waits for reconnection to complete before returning, - instead of the previous fire-and-forget pattern that caused the bug. + - Inside the loop (typical: from a coroutine): schedule a refresh as a + background task and return the (stale) attribute. The caller's await + will likely fail with a connection error and be retried by upper + layers (`call_with_db_reconnect_retry`); by that time the refresh + has either completed or escalated to the proactive loop's error + path. We CANNOT block here — `run_coroutine_threadsafe(...)` + + `future.result()` from inside the same loop deadlocks the loop + (loop thread is blocked, scheduled coroutine never runs, 30s timeout). + + - No running loop (sync caller, mostly tests): run the refresh in a + fresh loop and re-fetch the attribute. """ original_attr = getattr(self._original_prisma, name) if self.iam_token_db_auth: - db_url = os.getenv("DATABASE_URL") + db_url = os.getenv(self._db_url_env_var) # Check if token is expired (should be rare if background task is running) if self.is_token_expired(db_url): - verbose_proxy_logger.warning( - "RDS IAM token expired in __getattr__ - proactive refresh may have failed. " - "Triggering synchronous fallback refresh..." - ) + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None - new_db_url = self.get_rds_iam_token() - if new_db_url: - loop = asyncio.get_event_loop() - - if loop.is_running(): - # FIXED: Actually wait for the reconnection to complete! - # The previous code used fire-and-forget which caused the bug. - future = asyncio.run_coroutine_threadsafe( - self.recreate_prisma_client(new_db_url), loop - ) - try: - # Wait up to 30 seconds for reconnection - future.result(timeout=30) - verbose_proxy_logger.info( - "Synchronous token refresh completed successfully" - ) - except Exception as e: - verbose_proxy_logger.error( - f"Failed to refresh token synchronously: {e}" - ) - raise - else: - asyncio.run(self.recreate_prisma_client(new_db_url)) - - # Get the NEW attribute after reconnection - original_attr = getattr(self._original_prisma, name) + if running_loop is not None: + verbose_proxy_logger.warning( + "%sRDS IAM token expired in __getattr__ — proactive refresh " + "may have failed. Scheduling async refresh; the current " + "request may fail and be retried with the fresh token.", + self._log_prefix, + ) + # Non-blocking: schedule the locked refresh on the + # running loop. The reconnection lock inside + # `_safe_refresh_token` coalesces concurrent triggers. + running_loop.create_task(self._safe_refresh_token()) else: - raise ValueError("Failed to get RDS IAM token") + verbose_proxy_logger.warning( + "%sRDS IAM token expired in __getattr__ — proactive refresh " + "may have failed. Triggering synchronous fallback refresh...", + self._log_prefix, + ) + new_db_url = self.get_rds_iam_token() + if new_db_url: + asyncio.run(self.recreate_prisma_client(new_db_url)) + # Re-fetch attribute against the recreated Prisma instance. + original_attr = getattr(self._original_prisma, name) + verbose_proxy_logger.info( + "%sSynchronous token refresh completed successfully", + self._log_prefix, + ) + else: + raise ValueError("Failed to get RDS IAM token") return original_attr diff --git a/litellm/proxy/db/routing_prisma_wrapper.py b/litellm/proxy/db/routing_prisma_wrapper.py new file mode 100644 index 0000000000..0a976e9f1e --- /dev/null +++ b/litellm/proxy/db/routing_prisma_wrapper.py @@ -0,0 +1,213 @@ +""" +RoutingPrismaWrapper: routes Prisma reads to a read-replica client and writes +to a writer client. Used when DATABASE_URL_READ_REPLICA is configured; +otherwise PrismaClient uses the writer-only PrismaWrapper directly. +""" + +import os +from typing import Any, Callable, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.db.prisma_client import PrismaWrapper + +# Per-model action methods that read from the database. These are routed to +# the read replica when one is configured. +_MODEL_READ_METHODS = frozenset( + { + "find_first", + "find_first_or_raise", + "find_many", + "find_unique", + "find_unique_or_raise", + "count", + "group_by", + "query_first", + "query_raw", + } +) + +# Top-level Prisma client methods that read from the database. +_TOP_LEVEL_READ_METHODS = frozenset({"query_first", "query_raw"}) + + +class _RoutedActions: + """Per-model accessor that sends reads to the reader and writes to the writer. + + `should_use_reader` is consulted on every read dispatch so a mid-call flip + of the routing wrapper's reader-availability flag (e.g. after the reader + fails a recreate) is observed without re-fetching the actions accessor. + """ + + __slots__ = ("_writer_actions", "_reader_actions", "_should_use_reader") + + def __init__( + self, + writer_actions: Any, + reader_actions: Any, + should_use_reader: Callable[[], bool], + ): + self._writer_actions = writer_actions + self._reader_actions = reader_actions + self._should_use_reader = should_use_reader + + def __getattr__(self, name: str) -> Any: + if name in _MODEL_READ_METHODS and self._should_use_reader(): + return getattr(self._reader_actions, name) + return getattr(self._writer_actions, name) + + +class RoutingPrismaWrapper: + """ + Routes Prisma operations between a writer and a reader Prisma client. + + Reads (find_*, count, group_by, query_raw, query_first) go to the reader; + everything else (writes, transactions, raw execute) goes to the writer. + Lifecycle methods (connect, disconnect, IAM token refresh) act on both + clients so callers do not need to know about the split. When + IAM_TOKEN_DB_AUTH is enabled, both writer and reader refresh their tokens + independently on their own ~12-minute cadence. + + Reader degradation: a reader-side failure (failed connect, failed + recreate) is non-fatal — the wrapper sets `_reader_unavailable=True`, logs + a warning, and routes subsequent reads to the writer. The next successful + `connect()` or `recreate_prisma_client()` clears the flag. This keeps the + proxy serving traffic during transient reader outages instead of failing + startup or returning errors for read-heavy endpoints. + """ + + def __init__(self, writer: PrismaWrapper, reader: PrismaWrapper): + self._writer = writer + self._reader = reader + # When True, reads fall back to the writer. Flipped on by reader + # connect/recreate failures and flipped off on the next reader recovery. + self._reader_unavailable: bool = False + + @property + def writer(self) -> PrismaWrapper: + return self._writer + + @property + def reader(self) -> PrismaWrapper: + return self._reader + + @property + def reader_unavailable(self) -> bool: + return self._reader_unavailable + + def _should_use_reader(self) -> bool: + return not self._reader_unavailable + + async def connect(self, *args: Any, **kwargs: Any) -> None: + await self._writer.connect(*args, **kwargs) + verbose_proxy_logger.info("[writer] DB connected") + try: + await self._reader.connect(*args, **kwargs) + self._reader_unavailable = False + verbose_proxy_logger.info("[reader] DB connected") + except Exception as e: + # Degrade gracefully: the proxy keeps serving traffic with reads + # routed to the writer until the reader endpoint is reachable. + # Aborting startup here would tie proxy availability to an + # opt-in, best-effort reader endpoint. + self._reader_unavailable = True + verbose_proxy_logger.warning( + "Failed to connect to read replica DB: %s. " + "Falling back to the writer for reads until the reader is reachable.", + e, + ) + + async def disconnect(self, *args: Any, **kwargs: Any) -> None: + first_error: Optional[BaseException] = None + for client in (self._writer, self._reader): + try: + await client.disconnect(*args, **kwargs) + except Exception as e: + if first_error is None: + first_error = e + verbose_proxy_logger.warning("Error disconnecting Prisma client: %s", e) + if first_error is not None: + raise first_error + + def is_connected(self) -> bool: + # Reflects writer health only. The reader is best-effort; its + # availability is tracked via `_reader_unavailable` and a degraded + # reader must NOT cause a writer reconnect (would loop indefinitely + # since recreate_prisma_client only fixes writer-side problems). + return bool(self._writer.is_connected()) + + async def start_token_refresh_task(self) -> None: + await self._writer.start_token_refresh_task() + await self._reader.start_token_refresh_task() + + async def stop_token_refresh_task(self) -> None: + await self._writer.stop_token_refresh_task() + await self._reader.stop_token_refresh_task() + + async def recreate_prisma_client( + self, new_db_url: str, http_client: Optional[Any] = None + ) -> None: + """Recreate both writer and reader Prisma clients. + + The writer reconnect path in PrismaClient calls + `self.db.recreate_prisma_client(...)`. Without this method, a DB-wide + connectivity event would only re-create the writer; the reader engine + would stay broken and every routed read would fail. We always recreate + the writer first (its URL is the one passed in), then best-effort + recreate the reader. A reader failure flips `_reader_unavailable=True` + so reads transparently fall through to the writer. + """ + await self._writer.recreate_prisma_client(new_db_url, http_client=http_client) + try: + await self._recreate_reader(http_client=http_client) + self._reader_unavailable = False + except Exception as e: + self._reader_unavailable = True + verbose_proxy_logger.warning( + "Failed to recreate reader Prisma client: %s. " + "Reads will fall back to the writer until the reader recovers.", + e, + ) + + async def _recreate_reader(self, http_client: Optional[Any] = None) -> None: + """Resolve the reader URL and recreate its Prisma client. + + IAM-enabled readers regenerate their token (host/port/user came from + the parsed reader URL at construction time). Non-IAM readers reuse + the URL stored in `DATABASE_URL_READ_REPLICA`. + """ + if self._reader.iam_token_db_auth: + new_reader_url = self._reader.get_rds_iam_token() + if not new_reader_url: + raise RuntimeError( + "Failed to generate fresh IAM token for read replica" + ) + await self._reader.recreate_prisma_client( + new_reader_url, http_client=http_client + ) + return + reader_url = os.getenv("DATABASE_URL_READ_REPLICA", "") + if not reader_url: + raise RuntimeError( + "DATABASE_URL_READ_REPLICA not set; cannot recreate read replica client" + ) + await self._reader.recreate_prisma_client(reader_url, http_client=http_client) + + def __getattr__(self, name: str) -> Any: + if name in _TOP_LEVEL_READ_METHODS: + target = self._writer if self._reader_unavailable else self._reader + return getattr(target, name) + writer_attr = getattr(self._writer, name) + # Per-model action accessors are non-callable instances that expose + # both `find_many` and `create`. Methods like execute_raw / batch_ / + # tx are callables and stay on the writer untouched. + if ( + not callable(writer_attr) + and hasattr(writer_attr, "find_many") + and hasattr(writer_attr, "create") + ): + try: + reader_attr = getattr(self._reader, name) + except AttributeError: + return writer_attr + return _RoutedActions(writer_attr, reader_attr, self._should_use_reader) + return writer_attr diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 868b23756d..838fb2e01a 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -482,6 +482,11 @@ class InMemoryGuardrailHandler: "skip_system_message_in_guardrail", getattr(litellm_params, "skip_system_message_in_guardrail", None), ) + setattr( + custom_guardrail_callback, + "skip_tool_message_in_guardrail", + getattr(litellm_params, "skip_tool_message_in_guardrail", None), + ) parsed_guardrail = Guardrail( guardrail_id=guardrail.get("guardrail_id"), diff --git a/litellm/proxy/health_endpoints/health_app_factory.py b/litellm/proxy/health_endpoints/health_app_factory.py deleted file mode 100644 index c4fe383365..0000000000 --- a/litellm/proxy/health_endpoints/health_app_factory.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import FastAPI -from litellm.proxy.health_endpoints._health_endpoints import router as health_router - - -def build_health_app(): - health_app = FastAPI(title="LiteLLM Health Endpoints") - health_app.include_router(health_router) - return health_app diff --git a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py index f7c0592992..861083e7df 100644 --- a/litellm/proxy/hooks/dynamic_rate_limiter_v3.py +++ b/litellm/proxy/hooks/dynamic_rate_limiter_v3.py @@ -498,6 +498,8 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): "error": f"Model capacity reached for {model}. " f"Priority: {priority}, " f"Rate limit type: {status['rate_limit_type']}, " + f"Model TPM: {model_group_info.tpm if model_group_info.tpm is not None else 'not configured'}, " + f"Model RPM: {model_group_info.rpm if model_group_info.rpm is not None else 'not configured'}, " f"Remaining: {status['limit_remaining']}" }, headers={ @@ -515,8 +517,11 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger): status_code=429, detail={ "error": f"Priority-based rate limit exceeded. " + f"Model: {model}, " f"Priority: {priority}, " f"Rate limit type: {status['rate_limit_type']}, " + f"Model TPM: {model_group_info.tpm if model_group_info.tpm is not None else 'not configured'}, " + f"Model RPM: {model_group_info.rpm if model_group_info.rpm is not None else 'not configured'}, " f"Remaining: {status['limit_remaining']}, " f"Model saturation: {saturation:.1%}" }, diff --git a/litellm/proxy/hooks/model_max_budget_limiter.py b/litellm/proxy/hooks/model_max_budget_limiter.py index 95ffafb7ba..9286424878 100644 --- a/litellm/proxy/hooks/model_max_budget_limiter.py +++ b/litellm/proxy/hooks/model_max_budget_limiter.py @@ -319,6 +319,9 @@ class _PROXY_VirtualKeyModelMaxBudgetLimiter(RouterBudgetLimiting): response_cost=response_cost, ) + if self.dual_cache.redis_cache is not None: + await self._push_in_memory_increments_to_redis() + verbose_proxy_logger.debug( "current state of in memory cache %s", json.dumps( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 49230c65ec..a63613c583 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1667,7 +1667,10 @@ async def add_litellm_data_to_request( # noqa: PLR0915 ) if tags is not None and _admin_allow_client_tags: - data[_metadata_variable_name]["tags"] = tags + data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags( + request_tags=data[_metadata_variable_name].get("tags"), + tags_to_add=tags, + ) elif tags is not None: verbose_proxy_logger.warning( "Ignored caller-supplied tags from header/root body: this " diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index ac66adc26f..d173cd745b 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,3 +1,4 @@ +import asyncio from datetime import datetime, timedelta from types import SimpleNamespace from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union @@ -543,6 +544,13 @@ def _build_aggregated_sql_query( where_clause = " AND ".join(sql_conditions) + # Postgres computes every rollup level the response needs — per-date + # totals, per-(date, model), per-(date, model, api_key), per-provider, + # etc. — in a single pass via GROUPING SETS. The GROUPING() bitmask + # encodes which level a row belongs to so Python can dispatch rows + # straight into their buckets without re-summing. The leaf grouping + # is omitted on purpose: nothing in the response shape needs it once + # all the rollups are present. sql_query = f""" SELECT date, @@ -552,6 +560,9 @@ def _build_aggregated_sql_query( custom_llm_provider, mcp_namespaced_tool_name, endpoint, + GROUPING(date, api_key, model, model_group, + custom_llm_provider, mcp_namespaced_tool_name, + endpoint) AS group_level, SUM(spend)::float AS spend, SUM(prompt_tokens)::bigint AS prompt_tokens, SUM(completion_tokens)::bigint AS completion_tokens, @@ -562,32 +573,35 @@ def _build_aggregated_sql_query( SUM(failed_requests)::bigint AS failed_requests FROM "{pg_table}" WHERE {where_clause} - GROUP BY date, api_key, model, model_group, custom_llm_provider, - mcp_namespaced_tool_name, endpoint - ORDER BY date DESC + GROUP BY GROUPING SETS ( + (date), + (date, api_key), + (date, model), + (date, model, api_key), + (date, model_group), + (date, model_group, api_key), + (date, custom_llm_provider), + (date, custom_llm_provider, api_key), + (date, mcp_namespaced_tool_name), + (date, mcp_namespaced_tool_name, api_key), + (date, endpoint), + (date, endpoint, api_key), + () + ) """ return sql_query, sql_params -async def _aggregate_spend_records( +def _aggregate_spend_records_sync( *, - prisma_client: PrismaClient, records: List[Any], + api_key_metadata: Dict[str, Dict[str, Any]], entity_id_field: Optional[str], entity_metadata_field: Optional[Dict[str, dict]], ) -> Dict[str, Any]: - """Aggregate rows into DailySpendData list and total metrics.""" - api_keys: Set[str] = set() - for record in records: - if record.api_key: - api_keys.add(record.api_key) - - api_key_metadata: Dict[str, Dict[str, Any]] = {} model_metadata: Dict[str, Dict[str, Any]] = {} provider_metadata: Dict[str, Dict[str, Any]] = {} - if api_keys: - api_key_metadata = await get_api_key_metadata(prisma_client, api_keys) results: List[DailySpendData] = [] total_metrics = SpendMetrics() @@ -631,6 +645,228 @@ async def _aggregate_spend_records( return {"results": results, "totals": total_metrics} +async def _aggregate_spend_records( + *, + prisma_client: PrismaClient, + records: List[Any], + entity_id_field: Optional[str], + entity_metadata_field: Optional[Dict[str, dict]], +) -> Dict[str, Any]: + """Aggregate rows into DailySpendData list and total metrics. + + The per-row loop is offloaded to a worker thread via asyncio.to_thread so + a large result set doesn't peg the event loop. + """ + api_keys: Set[str] = {record.api_key for record in records if record.api_key} + + api_key_metadata: Dict[str, Dict[str, Any]] = {} + if api_keys: + api_key_metadata = await get_api_key_metadata(prisma_client, api_keys) + + return await asyncio.to_thread( + _aggregate_spend_records_sync, + records=records, + api_key_metadata=api_key_metadata, + entity_id_field=entity_id_field, + entity_metadata_field=entity_metadata_field, + ) + + +# GROUPING() bitmask values for each grouping set emitted by +# _build_aggregated_sql_query. Per Postgres semantics, the rightmost argument +# is the least-significant bit. Argument order: +# date, api_key, model, model_group, custom_llm_provider, +# mcp_namespaced_tool_name, endpoint +# A bit is 1 when the corresponding column is rolled up (i.e. NOT in the +# current grouping set's key), 0 when the column is part of the key. +_GROUP_GRAND_TOTAL = 127 # 0b1111111 — all rolled up +_GROUP_DATE = 63 # 0b0111111 — only date kept +_GROUP_DATE_API_KEY = 31 # 0b0011111 +_GROUP_DATE_MODEL = 47 # 0b0101111 +_GROUP_DATE_MODEL_API_KEY = 15 # 0b0001111 +_GROUP_DATE_MODEL_GROUP = 55 # 0b0110111 +_GROUP_DATE_MODEL_GROUP_API_KEY = 23 # 0b0010111 +_GROUP_DATE_PROVIDER = 59 # 0b0111011 +_GROUP_DATE_PROVIDER_API_KEY = 27 # 0b0011011 +_GROUP_DATE_MCP = 61 # 0b0111101 +_GROUP_DATE_MCP_API_KEY = 29 # 0b0011101 +_GROUP_DATE_ENDPOINT = 62 # 0b0111110 +_GROUP_DATE_ENDPOINT_API_KEY = 30 # 0b0011110 + + +def _record_to_spend_metrics(record: Any) -> SpendMetrics: + """Build a SpendMetrics directly from one already-aggregated rollup row.""" + return SpendMetrics( + spend=record.spend, + prompt_tokens=record.prompt_tokens, + completion_tokens=record.completion_tokens, + total_tokens=record.prompt_tokens + record.completion_tokens, + cache_read_input_tokens=record.cache_read_input_tokens, + cache_creation_input_tokens=record.cache_creation_input_tokens, + api_requests=record.api_requests, + successful_requests=record.successful_requests, + failed_requests=record.failed_requests, + ) + + +def _key_metadata( + api_key_metadata: Dict[str, Dict[str, Any]], api_key: str +) -> KeyMetadata: + meta = api_key_metadata.get(api_key, {}) + return KeyMetadata(key_alias=meta.get("key_alias"), team_id=meta.get("team_id")) + + +def _aggregate_grouping_sets_records_sync( # noqa: PLR0915 + *, + records: List[Any], + api_key_metadata: Dict[str, Dict[str, Any]], +) -> Dict[str, Any]: + """Build the response from rollup rows produced by the GROUPING SETS query. + + Each row carries a `group_level` bitmask (from Postgres GROUPING()) that + identifies which rollup level it belongs to. We dispatch the row's + pre-aggregated metrics straight into the matching bucket — no per-row + summing in Python and no nested update_metrics calls. + """ + total_metrics = SpendMetrics() + grouped_data: Dict[str, Dict[str, Any]] = {} + + def ensure_date(date_str: str) -> Dict[str, Any]: + bucket = grouped_data.get(date_str) + if bucket is None: + bucket = {"metrics": SpendMetrics(), "breakdown": BreakdownMetrics()} + grouped_data[date_str] = bucket + return bucket + + def assign_metric_with_metadata( + target: Dict[str, MetricWithMetadata], key: str, metrics: SpendMetrics + ) -> None: + existing = target.get(key) + if existing is None: + target[key] = MetricWithMetadata(metrics=metrics, metadata={}) + else: + existing.metrics = metrics + + def assign_api_key_breakdown( + target: Dict[str, MetricWithMetadata], + parent_key: str, + api_key: str, + metrics: SpendMetrics, + ) -> None: + parent = target.get(parent_key) + if parent is None: + parent = MetricWithMetadata(metrics=SpendMetrics(), metadata={}) + target[parent_key] = parent + parent.api_key_breakdown[api_key] = KeyMetricWithMetadata( + metrics=metrics, metadata=_key_metadata(api_key_metadata, api_key) + ) + + for record in records: + level = record.group_level + metrics = _record_to_spend_metrics(record) + + if level == _GROUP_GRAND_TOTAL: + total_metrics = metrics + continue + + if level == _GROUP_DATE: + ensure_date(record.date)["metrics"] = metrics + continue + + breakdown = ensure_date(record.date)["breakdown"] + + if level == _GROUP_DATE_API_KEY: + if record.api_key: + breakdown.api_keys[record.api_key] = KeyMetricWithMetadata( + metrics=metrics, + metadata=_key_metadata(api_key_metadata, record.api_key), + ) + elif level == _GROUP_DATE_MODEL: + if record.model: + assign_metric_with_metadata(breakdown.models, record.model, metrics) + elif level == _GROUP_DATE_MODEL_API_KEY: + if record.model and record.api_key: + assign_api_key_breakdown( + breakdown.models, record.model, record.api_key, metrics + ) + elif level == _GROUP_DATE_MODEL_GROUP: + if record.model_group: + assign_metric_with_metadata( + breakdown.model_groups, record.model_group, metrics + ) + elif level == _GROUP_DATE_MODEL_GROUP_API_KEY: + if record.model_group and record.api_key: + assign_api_key_breakdown( + breakdown.model_groups, + record.model_group, + record.api_key, + metrics, + ) + elif level == _GROUP_DATE_PROVIDER: + provider = record.custom_llm_provider or "unknown" + assign_metric_with_metadata(breakdown.providers, provider, metrics) + elif level == _GROUP_DATE_PROVIDER_API_KEY: + if record.api_key: + provider = record.custom_llm_provider or "unknown" + assign_api_key_breakdown( + breakdown.providers, provider, record.api_key, metrics + ) + elif level == _GROUP_DATE_MCP: + if record.mcp_namespaced_tool_name: + assign_metric_with_metadata( + breakdown.mcp_servers, record.mcp_namespaced_tool_name, metrics + ) + elif level == _GROUP_DATE_MCP_API_KEY: + if record.mcp_namespaced_tool_name and record.api_key: + assign_api_key_breakdown( + breakdown.mcp_servers, + record.mcp_namespaced_tool_name, + record.api_key, + metrics, + ) + elif level == _GROUP_DATE_ENDPOINT: + if record.endpoint: + assign_metric_with_metadata( + breakdown.endpoints, record.endpoint, metrics + ) + elif level == _GROUP_DATE_ENDPOINT_API_KEY: + if record.endpoint and record.api_key: + assign_api_key_breakdown( + breakdown.endpoints, record.endpoint, record.api_key, metrics + ) + + results = [ + DailySpendData( + date=datetime.strptime(date_str, "%Y-%m-%d").date(), + metrics=data["metrics"], + breakdown=data["breakdown"], + ) + for date_str, data in grouped_data.items() + ] + results.sort(key=lambda x: x.date, reverse=True) + + return {"results": results, "totals": total_metrics} + + +async def _aggregate_grouping_sets_records( + *, + prisma_client: PrismaClient, + records: List[Any], +) -> Dict[str, Any]: + """Async wrapper: fetch api_key_metadata, then dispatch on a worker thread.""" + api_keys: Set[str] = {r.api_key for r in records if r.api_key} + + api_key_metadata: Dict[str, Dict[str, Any]] = {} + if api_keys: + api_key_metadata = await get_api_key_metadata(prisma_client, api_keys) + + return await asyncio.to_thread( + _aggregate_grouping_sets_records_sync, + records=records, + api_key_metadata=api_key_metadata, + ) + + async def get_daily_activity( prisma_client: Optional[PrismaClient], table_name: str, @@ -771,21 +1007,18 @@ async def get_daily_activity_aggregated( timezone_offset_minutes=timezone_offset_minutes, ) - # Execute GROUP BY query — returns pre-aggregated dicts + # Execute GROUPING SETS query — returns one row per rollup level. rows = await prisma_client.db.query_raw(sql_query, *sql_params) if rows is None: rows = [] - # Convert dicts to objects for compatibility with _aggregate_spend_records records = [SimpleNamespace(**row) for row in rows] - # entity_id_field=None skips entity breakdown (entity dimension was - # collapsed by the GROUP BY, so per-entity data is not available) - aggregated = await _aggregate_spend_records( + # The grouping-sets dispatcher places each row directly in its bucket + # using the row's GROUPING() bitmask. No Python-side summing needed. + aggregated = await _aggregate_grouping_sets_records( prisma_client=prisma_client, records=records, - entity_id_field=None, - entity_metadata_field=None, ) return SpendAnalyticsPaginatedResponse( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index c62e40e90d..7bda0f87cc 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -152,8 +152,14 @@ if MCP_AVAILABLE: UserAPIKeyAuth, UserMCPManagementMode, ) - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - from litellm.proxy.common_utils.http_parsing_utils import _read_request_body + from litellm.proxy.auth.user_api_key_auth import ( + _user_api_key_auth_builder, + user_api_key_auth, + ) + from litellm.proxy.common_utils.http_parsing_utils import ( + _read_request_body, + populate_request_with_path_params, + ) from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.types.mcp import MCPCredentials @@ -1492,6 +1498,55 @@ if MCP_AVAILABLE: return _redact_mcp_credentials(temp_record) + async def _mcp_oauth_user_api_key_auth(request: Request) -> UserAPIKeyAuth: + """ + Auth dependency for MCP OAuth browser-navigation endpoints (/authorize, /token). + + Tries the Authorization header first. Falls back to decoding the UI + 'token' session cookie (set by SSO login) to extract the API key, which + allows browser-based OAuth redirects to work without an explicit + Authorization header. + """ + import jwt as _jwt + + from litellm.proxy.proxy_server import master_key + + auth_header = request.headers.get("Authorization", "") + api_key = auth_header # _get_bearer_token will strip "Bearer " prefix + + if not api_key: + token_cookie = request.cookies.get("token") + if token_cookie and master_key: + try: + decoded = _jwt.decode( + token_cookie, + master_key, + algorithms=["HS256"], + # UI session cookies may omit exp; don't require it. + options={"verify_exp": False}, + ) + if decoded.get("login_method") in ("sso", "username_password"): + cookie_key = decoded.get("key", "") + if cookie_key: + api_key = f"Bearer {cookie_key}" + except _jwt.InvalidTokenError: + pass + + request_data = await _read_request_body(request=request) + request_data = populate_request_with_path_params( + request_data=request_data, request=request + ) + + return await _user_api_key_auth_builder( + request=request, + api_key=api_key, + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data=request_data, + ) + async def _get_cached_temporary_mcp_server_or_404( server_id: str, user_api_key_dict: UserAPIKeyAuth, @@ -1542,12 +1597,12 @@ if MCP_AVAILABLE: @router.get( "/server/oauth/{server_id}/authorize", include_in_schema=False, - dependencies=[Depends(user_api_key_auth)], + dependencies=[Depends(_mcp_oauth_user_api_key_auth)], ) async def mcp_authorize( request: Request, server_id: str, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + user_api_key_dict: UserAPIKeyAuth = Depends(_mcp_oauth_user_api_key_auth), client_id: Optional[str] = None, redirect_uri: str = Query(...), state: str = "", @@ -1587,12 +1642,12 @@ if MCP_AVAILABLE: @router.post( "/server/oauth/{server_id}/token", include_in_schema=False, - dependencies=[Depends(user_api_key_auth)], + dependencies=[Depends(_mcp_oauth_user_api_key_auth)], ) async def mcp_token( request: Request, server_id: str, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + user_api_key_dict: UserAPIKeyAuth = Depends(_mcp_oauth_user_api_key_auth), grant_type: str = Form(...), code: Optional[str] = Form(None), redirect_uri: Optional[str] = Form(None), diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 04699f19ff..1f20764f83 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -4,6 +4,7 @@ This is an enterprise feature and requires a premium license. """ +import re from typing import Any, Dict, List, Optional, Set, Tuple from fastapi import ( @@ -843,6 +844,18 @@ async def get_service_provider_config(request: Request): return SCIMServiceProviderConfig(meta=meta) +def _parse_scim_eq_filter(scim_filter: str) -> Optional[Tuple[str, str]]: + """Parse the SCIM equality filters Okta uses before user lifecycle changes.""" + match = re.match( + r"""\s*([\w.]+)\s+eq\s+(['"]?)(.*?)\2\s*$""", + scim_filter, + flags=re.IGNORECASE, + ) + if not match: + return None + return match.group(1).lower(), match.group(3) + + # User Endpoints @scim_router.get( "/Users", @@ -867,15 +880,21 @@ async def get_users( try: prisma_client = await _get_prisma_client_or_raise_exception() # Parse filter if provided (basic support) - where_conditions = {} + where_conditions: Dict[str, Any] = {} if filter: - # Very basic filter support - only handling userName eq and emails.value eq - if "userName eq" in filter: - user_id = filter.split("userName eq ")[1].strip("\"'") - where_conditions["user_id"] = user_id - elif "emails.value eq" in filter: - email = filter.split("emails.value eq ")[1].strip("\"'") - where_conditions["user_email"] = email + # Okta locates users by userName before deprovisioning. LiteLLM + # exposes SCIM userName from user_email, while older SCIM-created + # users may still have user_id == userName, so support both. + parsed_filter = _parse_scim_eq_filter(filter) + if parsed_filter: + filter_attribute, filter_value = parsed_filter + if filter_attribute == "username": + where_conditions["OR"] = [ + {"user_email": filter_value}, + {"user_id": filter_value}, + ] + elif filter_attribute == "emails.value": + where_conditions["user_email"] = filter_value # Get users from database users: List[LiteLLM_UserTable] = ( diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 74ee7c7220..b13118db6f 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -4078,6 +4078,8 @@ async def debug_sso_callback(request: Request): redirect_url += "/sso/debug/callback" result = None + received_response: Optional[dict] = None + access_token_payload: Optional[dict] = None if google_client_id is not None: result = await GoogleSSOHandler.get_google_callback_response( request=request, @@ -4094,12 +4096,14 @@ async def debug_sso_callback(request: Request): ) elif generic_client_id is not None: - result, _, _ = await get_generic_sso_response( - request=request, - jwt_handler=jwt_handler, - generic_client_id=generic_client_id, - redirect_url=redirect_url, - sso_jwt_handler=sso_jwt_handler, + result, received_response, access_token_payload = ( + await get_generic_sso_response( + request=request, + jwt_handler=jwt_handler, + generic_client_id=generic_client_id, + redirect_url=redirect_url, + sso_jwt_handler=sso_jwt_handler, + ) ) # If result is None, return a basic error message @@ -4128,10 +4132,32 @@ async def debug_sso_callback(request: Request): except Exception as e: filtered_result[key] = f"Complex value (not displayable): {str(e)}" + # Defense-in-depth: ensure no bearer tokens leak into the rendered HTML even if + # a non-conforming IdP places them in its userinfo response. + safe_raw_claims = { + k: v + for k, v in (received_response or {}).items() + if k not in _OAUTH_TOKEN_FIELDS + } + safe_access_token_claims = { + k: v + for k, v in (access_token_payload or {}).items() + if k not in _OAUTH_TOKEN_FIELDS + } + + sso_payload = { + "parsed_by_proxy": filtered_result, + "raw_claims": safe_raw_claims, + "access_token_claims": safe_access_token_claims, + } + # Replace the placeholder in the template with the actual data + sso_payload_json = json.dumps(sso_payload, indent=2, default=str).replace( + " Optional[CustomLogger]: - """Resolve a string callback name to a CustomLogger instance, with caching.""" + """Resolve a string callback name to a CustomLogger instance, with caching. + + For "s3_v2" with `litellm.s3_audit_callback_params` set, constructs a + dedicated `S3Logger` so audit logs can target a different bucket than the + normal-log singleton served by `_init_custom_logger_compatible_class`. + """ if name in _audit_log_callback_cache: return _audit_log_callback_cache[name] - from litellm.litellm_core_utils.litellm_logging import ( - _init_custom_logger_compatible_class, - ) + instance: Optional[CustomLogger] + if ( + name == "s3_v2" + and getattr(litellm, "s3_audit_callback_params", None) is not None + ): + from litellm.integrations.s3_v2 import S3Logger as S3V2Logger - instance = _init_custom_logger_compatible_class( - logging_integration=name, # type: ignore - internal_usage_cache=None, - llm_router=None, - ) + instance = S3V2Logger( + s3_callback_params_override=litellm.s3_audit_callback_params + ) + else: + from litellm.litellm_core_utils.litellm_logging import ( + _init_custom_logger_compatible_class, + ) + + instance = _init_custom_logger_compatible_class( + logging_integration=name, # type: ignore + internal_usage_cache=None, + llm_router=None, + ) if instance is not None: _audit_log_callback_cache[name] = instance return instance +def reset_audit_log_callback_cache() -> None: + """Clear cached audit-log callback instances. Call on config reload.""" + _audit_log_callback_cache.clear() + + def _build_audit_log_payload( request_data: LiteLLM_AuditLogs, ) -> StandardAuditLogPayload: diff --git a/litellm/proxy/middleware/prometheus_auth_middleware.py b/litellm/proxy/middleware/prometheus_auth_middleware.py index cfc4cbd64b..7eb8ae83cb 100644 --- a/litellm/proxy/middleware/prometheus_auth_middleware.py +++ b/litellm/proxy/middleware/prometheus_auth_middleware.py @@ -79,7 +79,10 @@ class PrometheusAuthMiddleware: # Send 401 response directly via ASGI protocol error_message = getattr(e, "message", str(e)) body = json.dumps( - f"Unauthorized access to metrics endpoint: {error_message}" + f"Unauthorized access to metrics endpoint: {error_message} " + f"To allow unauthenticated access, set " + f"`litellm_settings.require_auth_for_metrics_endpoint: false` " + f"in your proxy_config.yaml." ).encode("utf-8") await send( { diff --git a/litellm/proxy/middleware/request_size_limit_middleware.py b/litellm/proxy/middleware/request_size_limit_middleware.py new file mode 100644 index 0000000000..78a38e3572 --- /dev/null +++ b/litellm/proxy/middleware/request_size_limit_middleware.py @@ -0,0 +1,121 @@ +import json +from typing import Callable, Optional, Union + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +MaxRequestSizeGetter = Callable[[], Optional[Union[int, float]]] +RequestSizeLimitEnabledGetter = Callable[[], bool] + + +class RequestEntityTooLarge(Exception): + pass + + +class RequestSizeLimitMiddleware: + """ + Reject oversized requests before downstream auth/routes parse the body. + + Content-Length can be rejected without reading any body bytes. Requests + without Content-Length are counted as the ASGI stream is consumed, limiting + memory exposure to the configured threshold plus the current chunk. + """ + + def __init__( + self, + app: ASGIApp, + get_max_request_size_mb: MaxRequestSizeGetter, + is_request_size_limit_enabled: RequestSizeLimitEnabledGetter, + ) -> None: + self.app = app + self.get_max_request_size_mb = get_max_request_size_mb + self.is_request_size_limit_enabled = is_request_size_limit_enabled + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + max_request_size_mb = self.get_max_request_size_mb() + max_request_size_bytes = _mb_to_bytes(max_request_size_mb) + if max_request_size_bytes is None or not self.is_request_size_limit_enabled(): + await self.app(scope, receive, send) + return + + content_length = _get_content_length(scope=scope) + if content_length is not None and content_length > max_request_size_bytes: + await _send_request_too_large( + send=send, max_request_size_mb=max_request_size_mb + ) + return + + received_body_bytes = 0 + response_started = False + + async def limited_receive() -> Message: + nonlocal received_body_bytes + + message = await receive() + if message["type"] != "http.request": + return message + + received_body_bytes += len(message.get("body", b"")) + if received_body_bytes > max_request_size_bytes: + raise RequestEntityTooLarge + return message + + async def tracking_send(message: Message) -> None: + nonlocal response_started + + if message["type"] == "http.response.start": + response_started = True + await send(message) + + try: + await self.app(scope, limited_receive, tracking_send) + except RequestEntityTooLarge: + if response_started: + raise + await _send_request_too_large( + send=send, max_request_size_mb=max_request_size_mb + ) + + +def _mb_to_bytes(max_request_size_mb: Optional[Union[int, float]]) -> Optional[int]: + if max_request_size_mb is None: + return None + if max_request_size_mb <= 0: + return None + return int(max_request_size_mb * 1024 * 1024) + + +def _get_content_length(scope: Scope) -> Optional[int]: + headers = dict(scope.get("headers") or []) + raw_content_length = headers.get(b"content-length") + if raw_content_length is None: + return None + + try: + return int(raw_content_length) + except ValueError: + return None + + +async def _send_request_too_large( + send: Send, + max_request_size_mb: Optional[Union[int, float]], +) -> None: + body = json.dumps( + {"error": f"Request size is too large. Max size is {max_request_size_mb} MB"}, + separators=(",", ":"), + ).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": 413, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("latin-1")), + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 71aeea6788..6359b48654 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -169,6 +169,66 @@ class ProxyInitializationHelpers: ) return uvicorn_args + @staticmethod + def _get_reload_options(config_path: Optional[str]) -> dict: + """Build uvicorn reload kwargs so --reload also reacts to YAML edits.""" + options: dict = {"reload": True} + if not config_path: + return options + config_abs = os.path.abspath(config_path) + config_dir = os.path.dirname(config_abs) + cwd = os.path.abspath(os.getcwd()) + reload_dirs = [cwd] + if config_dir and config_dir != cwd: + reload_dirs.append(config_dir) + options["reload_dirs"] = reload_dirs + # Must be a basename, not an absolute path: uvicorn's + # resolve_reload_patterns() calls pathlib.Path.glob(), which raises + # NotImplementedError on absolute patterns (uvicorn discussion #2156). + options["reload_includes"] = ["*.py", os.path.basename(config_abs)] + return options + + @staticmethod + def _patch_statreload_for_config(config_path: str) -> bool: + """Make uvicorn's StatReload reloader notice YAML config changes. + + Uvicorn uses WatchFilesReload when the optional `watchfiles` package + is installed, otherwise StatReload. StatReload hard-codes `*.py` in + `iter_py_files()` and silently ignores `reload_includes`, so the + kwargs from `_get_reload_options` alone don't trigger reloads on YAML + edits. We monkey-patch `iter_py_files` to also yield the config path. + + Idempotent across calls and a no-op for the WatchFilesReload path. + """ + try: + from uvicorn.supervisors.statreload import StatReload + except ImportError: # pragma: no cover - uvicorn is a hard dep + return False + + if not config_path: + return False + + from pathlib import Path + + config_abs = Path(config_path).resolve() + + patched_paths = getattr(StatReload, "_litellm_patched_config_paths", None) + if patched_paths is None: + original_iter = StatReload.iter_py_files + patched_paths = set() + + def _iter_with_config(self): # type: ignore[no-untyped-def] + yield from original_iter(self) + for path in StatReload._litellm_patched_config_paths: + if path.exists(): + yield path + + StatReload.iter_py_files = _iter_with_config # type: ignore[assignment] + StatReload._litellm_patched_config_paths = patched_paths # type: ignore[attr-defined] + + patched_paths.add(config_abs) + return True + @staticmethod def _init_hypercorn_server( app: FastAPI, @@ -619,7 +679,7 @@ class ProxyInitializationHelpers: "--reload", is_flag=True, default=False, - help="Enable uvicorn hot reload (dev only). Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.", + help="Enable uvicorn hot reload (dev only). Also reloads when the --config YAML file changes. Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.", ) def run_server( # noqa: PLR0915 host, @@ -753,7 +813,12 @@ def run_server( # noqa: PLR0915 from litellm.proxy.auth.rds_iam_token import generate_iam_auth_token db_host = os.getenv("DATABASE_HOST") - db_port = os.getenv("DATABASE_PORT") + # Default to the Postgres standard port. Without a default, + # `db_port=None` flows into `boto.generate_db_auth_token(Port=None)` + # and botocore stringifies it to `"None"` while building the + # presigned URL, which then blows up with `ValueError: Port could + # not be cast to integer value as 'None'` during signing. + db_port = os.getenv("DATABASE_PORT", "5432") db_user = os.getenv("DATABASE_USER") db_name = os.getenv("DATABASE_NAME") db_schema = os.getenv("DATABASE_SCHEMA") @@ -990,11 +1055,6 @@ def run_server( # noqa: PLR0915 litellm_settings=litellm_settings if config else None, # type: ignore[possibly-unbound] ) - # --- SEPARATE HEALTH APP LOGIC --- - # To run the health app separately, use: - # uvicorn litellm.proxy.health_app_factory:build_health_app --factory --host 0.0.0.0 --port=4001 - # This is compatible with the SEPARATE_HEALTH_APP Docker/supervisord pattern. - # --- END SEPARATE HEALTH APP LOGIC --- # Skip server startup if requested (after all setup is done) if skip_server_startup: print( # noqa @@ -1028,7 +1088,11 @@ def run_server( # noqa: PLR0915 uvicorn_args["loop"] = loop_type if reload: - uvicorn_args["reload"] = True + uvicorn_args.update( + ProxyInitializationHelpers._get_reload_options(config) + ) + if config: + ProxyInitializationHelpers._patch_statreload_for_config(config) uvicorn.run( **uvicorn_args, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b136464fd2..a116b27f9f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -211,6 +211,7 @@ from litellm import Router from litellm._logging import verbose_proxy_logger, verbose_router_logger from litellm.caching.caching import DualCache, RedisCache from litellm.caching.redis_cluster_cache import RedisClusterCache +from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.constants import ( _REALTIME_BODY_CACHE_SIZE, @@ -403,6 +404,9 @@ from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware +from litellm.proxy.middleware.request_size_limit_middleware import ( + RequestSizeLimitMiddleware, +) from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router from litellm.proxy.openai_files_endpoints.files_endpoints import ( router as openai_files_router, @@ -3834,6 +3838,11 @@ class ProxyConfig: f"{blue_color_code} Initialized Failure Callbacks - {litellm.failure_callback} {reset_color_code}" ) # noqa elif key == "audit_log_callbacks": + from litellm.proxy.management_helpers.audit_logs import ( + reset_audit_log_callback_cache, + ) + + reset_audit_log_callback_cache() litellm.audit_log_callbacks = [] for callback in value: @@ -3915,6 +3924,21 @@ class ProxyConfig: f"{blue_color_code} setting litellm.{key}={value}{reset_color_code}" ) setattr(litellm, key, value) + if key in {"s3_audit_callback_params", "s3_callback_params"}: + from litellm.proxy.management_helpers.audit_logs import ( + reset_audit_log_callback_cache, + ) + from litellm.litellm_core_utils.litellm_logging import ( + _in_memory_loggers, + ) + from litellm.integrations.s3_v2 import S3Logger as S3V2Logger + + reset_audit_log_callback_cache() + _in_memory_loggers[:] = [ + cb + for cb in _in_memory_loggers + if not isinstance(cb, S3V2Logger) + ] ## GENERAL SERVER SETTINGS (e.g. master key,..) # do this after initializing litellm, to ensure sentry logging works for proxylogging general_settings = config.get("general_settings", {}) @@ -6741,27 +6765,64 @@ class ProxyStartupEvent: "budget_duration not set on Proxy. budget_duration is required to use max_budget." ) - # add proxy budget to db in the user table asyncio.create_task( - generate_key_helper_fn( # type: ignore - request_type="user", - table_name="user", - user_id=litellm_proxy_budget_name, - duration=None, - models=[], - aliases={}, - config={}, - spend=0, - max_budget=litellm.max_budget, - budget_duration=litellm.budget_duration, - query_type="update_data", - update_key_values={ - "max_budget": litellm.max_budget, - "budget_duration": litellm.budget_duration, - }, - ) + cls._upsert_proxy_budget_with_reset_at_backfill(litellm_proxy_budget_name) ) + @classmethod + async def _upsert_proxy_budget_with_reset_at_backfill( + cls, litellm_proxy_budget_name: str + ) -> None: + """ + Upsert the proxy admin user row with the configured max_budget / + budget_duration, then backfill budget_reset_at if currently NULL. + + The backfill uses `WHERE budget_reset_at IS NULL` so it only fires + when the row pre-existed without a reset schedule (e.g. row created + via a different path before the proxy budget was configured). On + subsequent restarts it no-ops, so an active reset window is never + slid forward. + """ + await generate_key_helper_fn( # type: ignore + request_type="user", + table_name="user", + user_id=litellm_proxy_budget_name, + duration=None, + models=[], + aliases={}, + config={}, + spend=0, + max_budget=litellm.max_budget, + budget_duration=litellm.budget_duration, + query_type="update_data", + update_key_values={ + "max_budget": litellm.max_budget, + "budget_duration": litellm.budget_duration, + }, + ) + + # Without this, the upsert leaves budget_reset_at=NULL on rows that + # took the UPDATE path, and reset_budget_for_litellm_users never + # matches them (NULL < now() is unknown in SQL) — so the proxy-wide + # spend cap blocks forever once it's hit. + if prisma_client is not None and litellm.budget_duration is not None: + try: + await prisma_client.db.litellm_usertable.update_many( + where={ + "user_id": litellm_proxy_budget_name, + "budget_reset_at": None, + }, + data={ + "budget_reset_at": get_budget_reset_time( + budget_duration=litellm.budget_duration + ) + }, + ) + except Exception as e: + verbose_proxy_logger.warning( + "Failed to backfill budget_reset_at on proxy admin row: %s", e + ) + @classmethod async def _warm_global_spend_cache( cls, @@ -8819,6 +8880,7 @@ def _realtime_query_params_template( return tuple(params) +@app.websocket("/openai/v1/realtime") @app.websocket("/v1/realtime") @app.websocket("/realtime") async def realtime_websocket_endpoint( @@ -14895,6 +14957,11 @@ app.include_router(ui_discovery_endpoints_router) app.include_router(google_router) attach_lazy_features(app) +app.add_middleware( + RequestSizeLimitMiddleware, + get_max_request_size_mb=lambda: general_settings.get("max_request_size_mb"), + is_request_size_limit_enabled=lambda: premium_user is True, +) async def _stream_mcp_asgi_response( diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 1d296611bf..200a17e236 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -95,11 +95,9 @@ async def reserve_budget_for_request( route=route, llm_router=llm_router, ) - if reservation_cost is None: - reservation_cost = await _get_smallest_remaining_budget( - counters=counters, - current_spend_by_counter_key=current_spend_by_counter_key, - ) + # estimate_request_max_cost still returns None when the model is unknown + # to the cost map (no token-priced cost fields, e.g. image/audio routes). + # In that case we fall back to read-time enforcement only. if reservation_cost is None or reservation_cost <= 0: return None @@ -553,32 +551,6 @@ def _coerce_window(window: Any) -> dict: return {} -async def _get_smallest_remaining_budget( - counters: List[_BudgetCounter], - current_spend_by_counter_key: Dict[str, float], -) -> Optional[float]: - remaining_budget: Optional[float] = None - for counter in counters: - current_spend = await _get_current_counter_value(counter=counter) - current_spend_by_counter_key[counter.counter_key] = current_spend - remaining = counter.max_budget - current_spend - if remaining <= 0: - raise litellm.BudgetExceededError( - current_cost=current_spend, - max_budget=counter.max_budget, - message=( - "Budget has been exceeded! " - f"{counter.entity_type}={counter.entity_id} " - f"Current cost: {current_spend}, " - f"Max budget: {counter.max_budget}" - ), - ) - remaining_budget = ( - remaining if remaining_budget is None else min(remaining_budget, remaining) - ) - return remaining_budget - - async def _reserve_counter( counter: _BudgetCounter, reservation_cost: float, @@ -855,6 +827,13 @@ def _estimate_request_max_cost_for_model( if model_info is None: return None + image_cost = _estimate_image_generation_cost( + request_body=request_body, + model_info=model_info, + ) + if image_cost is not None: + return image_cost + input_cost_per_token = _to_float(model_info.get("input_cost_per_token")) output_cost_per_token = _to_float(model_info.get("output_cost_per_token")) input_tokens = _estimate_input_tokens( @@ -886,6 +865,44 @@ def _estimate_request_max_cost_for_model( return cost +def _estimate_image_generation_cost( + request_body: dict, + model_info: Dict[str, Any], +) -> Optional[float]: + """ + Reserve `n × per-image cost` for image-generation requests so concurrent + requests against a depleted budget cannot all slip past the admission gate + onto the provider. Token-based pricing (e.g. gpt-image-1) is handled by + the chat-route token path; per-pixel and size/quality-tiered pricing + (DALL-E 2 size variants, premium tiers) are not handled here and fall + through to read-time enforcement. + + The "output" vs "input" cost-per-image naming is inconsistent across + providers — OpenAI's dall-e-3 entry uses ``input_cost_per_image`` while + aiml/dall-e-3 uses ``output_cost_per_image`` — so both are summed. + """ + # Gate strictly on `mode`. Several chat and embedding models carry + # ``input_cost_per_image`` / ``output_cost_per_image`` to price multimodal + # *vision input* (e.g. ``gemini-3.1-pro-preview``, ``azure/gpt-realtime-*``, + # ``amazon.titan-embed-image-v1``). Falling back to "treat as image-gen if + # an image cost field is present" would short-circuit the token-priced + # path for those models and reserve a fraction of a cent instead of the + # true per-token cost. All real image-generation entries in + # ``model_prices_and_context_window.json`` carry ``mode: image_generation`` + # or ``mode: image_edit``, so the field-presence fallback is unnecessary. + if model_info.get("mode") not in ("image_generation", "image_edit"): + return None + + output_cost_per_image = _to_float(model_info.get("output_cost_per_image")) + input_cost_per_image = _to_float(model_info.get("input_cost_per_image")) + cost_per_image = (output_cost_per_image or 0.0) + (input_cost_per_image or 0.0) + if cost_per_image <= 0: + return None + + n = _to_int(request_body.get("n")) or 1 + return cost_per_image * max(n, 1) + + def _get_model_cost_info( model: str, llm_router: Optional[Router], @@ -946,6 +963,9 @@ def _estimate_input_tokens( return None +DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK = 16384 + + def _estimate_output_tokens( request_body: dict, route: str, @@ -954,15 +974,27 @@ def _estimate_output_tokens( if _is_input_only_route(route=route): return 0 + requested: Optional[int] = None for key in ("max_completion_tokens", "max_tokens", "max_output_tokens"): - max_tokens = _to_int(request_body.get(key)) - if max_tokens is not None: - return max_tokens + requested = _to_int(request_body.get(key)) + if requested is not None: + break - # If the caller did not cap output tokens, avoid reserving a model's - # theoretical maximum context. The caller can still admit one request by - # reserving the smallest remaining budget in reserve_budget_for_request(). - return None + # Clamp at min(requested-or-default, model_max-or-default). Two purposes: + # (1) Without an explicit cap we still need a finite reservation so the + # atomic admission counter actually bounds concurrent in-flight cost + # (mirrors parallel_request_limiter_v3's DEFAULT_MAX_TOKENS_ESTIMATE). + # (2) An adversarial caller cannot send max_tokens=999999999 to inflate + # the reservation up to remaining team headroom and pin the counter + # at the cap — the model can only physically emit max_output_tokens + # anyway, so reserving more is both wasteful and a DoS surface. + model_ceiling = ( + _to_int(model_info.get("max_output_tokens")) + or DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK + ) + if requested is None: + requested = DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK + return min(requested, model_ceiling) def _count_text_tokens(model: str, text: Any) -> int: diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ac214ddabe..2998555db1 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -113,7 +113,11 @@ from litellm.proxy.db.exception_handler import ( call_with_db_reconnect_retry, ) from litellm.proxy.db.log_db_metrics import log_db_metrics -from litellm.proxy.db.prisma_client import PrismaWrapper +from litellm.proxy.db.prisma_client import ( + PrismaWrapper, + parse_iam_endpoint_from_url, +) +from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( UnifiedLLMGuardrails, ) @@ -2274,6 +2278,13 @@ class ProxyLogging: Covers: 1. /chat/completions """ + from litellm.proxy.proxy_server import llm_router + + # Merge model-level guardrails before checking which guardrails to run + request_data = _check_and_merge_model_level_guardrails( + data=request_data, llm_router=llm_router + ) + current_response = response for callback in litellm.callbacks: @@ -2562,24 +2573,101 @@ class PrismaClient: raise Exception( "Unable to find Prisma binaries. Please run 'prisma generate' first." ) + iam_flag = ( + self.iam_token_db_auth if self.iam_token_db_auth is not None else False + ) + # When read-replica routing is on, tag log lines with [writer]/[reader] + # so the two wrappers' interleaved IAM refresh logs can be told apart. + # Single-DB deployments get an empty prefix (logs unchanged). + read_replica_url = os.getenv("DATABASE_URL_READ_REPLICA") + writer_log_prefix = "[writer]" if read_replica_url else "" if http_client is not None: - self.db = PrismaWrapper( + writer_wrapper = PrismaWrapper( original_prisma=Prisma(http=http_client), - iam_token_db_auth=( - self.iam_token_db_auth - if self.iam_token_db_auth is not None - else False - ), + iam_token_db_auth=iam_flag, + log_prefix=writer_log_prefix, ) else: - self.db = PrismaWrapper( + writer_wrapper = PrismaWrapper( original_prisma=Prisma(), - iam_token_db_auth=( - self.iam_token_db_auth - if self.iam_token_db_auth is not None - else False - ), - ) # Client to connect to Prisma db + iam_token_db_auth=iam_flag, + log_prefix=writer_log_prefix, + ) + + # Optional read-replica routing. When DATABASE_URL_READ_REPLICA is set, + # reads (find_*, count, group_by, query_raw/_first) are routed to the + # reader endpoint and writes stay on the writer. Falls back to the + # writer-only wrapper when the env var is unset, preserving existing + # single-DB deployments. + self.db: Union[PrismaWrapper, RoutingPrismaWrapper] + if read_replica_url: + try: + # If IAM auth is enabled, the reader refreshes its own token on + # the same cadence as the writer. We parse the static endpoint + # pieces (host/port/user/db) once from the reader URL — only + # the IAM token rotates after that. + reader_iam_endpoint = ( + parse_iam_endpoint_from_url(read_replica_url) if iam_flag else None + ) + # Mint a fresh IAM token for the reader BEFORE constructing the + # Prisma client. Mirrors what `proxy_cli.py` already does for + # the writer (proxy_cli.py:812-832) — without this, the reader + # Prisma is built with whatever placeholder URL the user + # supplied (no real token), and the first query falls through + # to the synchronous fallback path in + # `PrismaWrapper.__getattr__`, which deadlocks the event loop + # and times out after 30s. + if iam_flag and reader_iam_endpoint is not None: + from litellm.proxy.auth.rds_iam_token import ( + generate_iam_auth_token, + ) + + reader_token = generate_iam_auth_token( + db_host=reader_iam_endpoint.host, + db_port=reader_iam_endpoint.port, + db_user=reader_iam_endpoint.user, + ) + read_replica_url = reader_iam_endpoint.build_url(reader_token) + os.environ["DATABASE_URL_READ_REPLICA"] = read_replica_url + reader_kwargs: Dict[str, Any] = { + "datasource": {"url": read_replica_url} + } + if http_client is not None: + reader_prisma = Prisma(http=http_client, **reader_kwargs) + else: + reader_prisma = Prisma(**reader_kwargs) + reader_wrapper = PrismaWrapper( + original_prisma=reader_prisma, + iam_token_db_auth=iam_flag, + db_url_env_var="DATABASE_URL_READ_REPLICA", + iam_endpoint=reader_iam_endpoint, + recreate_uses_datasource=True, + log_prefix="[reader]", + ) + self.db = RoutingPrismaWrapper( + writer=writer_wrapper, reader=reader_wrapper + ) + verbose_proxy_logger.info( + "PrismaClient: read-replica routing enabled via DATABASE_URL_READ_REPLICA" + + (" (with IAM token auto-refresh)" if iam_flag else "") + ) + except Exception as e: + # Reader is opt-in; never let its construction fail proxy + # startup. Mirrors the runtime contract from + # `RoutingPrismaWrapper.connect`: reader-side failures are + # logged and we keep serving traffic via the writer alone. + # This recovers from transient AWS STS hiccups during the + # reader IAM token mint, malformed DATABASE_URL_READ_REPLICA, + # and Prisma construction errors. Operator restart is required + # to retry read-routing once the underlying issue is resolved. + verbose_proxy_logger.warning( + "Failed to initialize read replica Prisma client: %s. " + "Falling back to writer-only mode (no read routing) until proxy restart.", + e, + ) + self.db = writer_wrapper + else: + self.db = writer_wrapper # Client to connect to Prisma db self._db_reconnect_lock = asyncio.Lock() self._db_health_watchdog_task: Optional[asyncio.Task] = None self._db_last_reconnect_attempt_ts: float = 0.0 @@ -2617,6 +2705,13 @@ class PrismaClient: self._engine_wait_thread: Optional[threading.Thread] = None verbose_proxy_logger.debug("Success - Created Prisma Client") + @property + def writer_db(self) -> PrismaWrapper: + """Underlying writer Prisma wrapper, regardless of read-replica routing.""" + if isinstance(self.db, RoutingPrismaWrapper): + return self.db.writer + return self.db + def get_request_status( self, payload: Union[dict, SpendLogsPayload] ) -> Literal["success", "failure"]: @@ -4265,7 +4360,10 @@ class PrismaClient: self._cleanup_engine_watcher() await self.db.recreate_prisma_client(db_url) await self._start_engine_watcher() - await self.db.query_raw("SELECT 1") + # Smoke-test the writer specifically; query_raw on the routing + # wrapper sends to the reader, which would not validate the + # newly-recreated writer engine. + await self.writer_db.query_raw("SELECT 1") await asyncio.wait_for(_do_direct_reconnect(), timeout=effective_timeout) diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 04347aebe3..751113400d 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -633,6 +633,16 @@ class BaseLitellmParams( ), ) + skip_tool_message_in_guardrail: Optional[bool] = Field( + default=None, + description=( + "When True, unified guardrails skip tool-role messages when building " + "evaluation inputs (texts and structured_messages). When False, tool " + "messages are included even if litellm_settings sets a global skip. When " + "None, use the global litellm.skip_tool_message_in_guardrail setting." + ), + ) + # Lakera specific params category_thresholds: Optional[LakeraCategoryThresholds] = Field( default=None, diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index ebabf3fb6f..21d4da8204 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -37,6 +37,7 @@ class MCPAuth(str, enum.Enum): oauth2 = "oauth2" aws_sigv4 = "aws_sigv4" token = "token" + oauth2_token_exchange = "oauth2_token_exchange" # MCP Literals @@ -54,6 +55,7 @@ MCPAuthType = Optional[ MCPAuth.oauth2, MCPAuth.aws_sigv4, MCPAuth.token, + MCPAuth.oauth2_token_exchange, ] ] @@ -117,6 +119,22 @@ class MCPCredentials(TypedDict, total=False): aws_session_name: Optional[str] """Session name for STS AssumeRole (used in CloudTrail). Not a secret — stored unencrypted.""" + audience: Optional[str] + """ + Target audience for OAuth 2.0 Token Exchange (RFC 8693) + """ + + token_exchange_endpoint: Optional[str] + """ + IDP token endpoint for OAuth 2.0 Token Exchange (RFC 8693) + """ + + subject_token_type: Optional[str] + """ + Subject token type for OAuth 2.0 Token Exchange (RFC 8693). + Default: urn:ietf:params:oauth:token-type:access_token + """ + class MCPServerCostInfo(TypedDict, total=False): default_cost_per_query: Optional[float] diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 8f8673b0a7..268d064eac 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -57,6 +57,10 @@ class MCPServer(BaseModel): aws_service_name: Optional[str] = None # defaults to "bedrock-agentcore" aws_role_name: Optional[str] = None # IAM role ARN for STS AssumeRole aws_session_name: Optional[str] = None # session name for CloudTrail auditing + # Token Exchange (OBO) fields — RFC 8693 + token_exchange_endpoint: Optional[str] = None + audience: Optional[str] = None + subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token" # Stdio-specific fields command: Optional[str] = None args: Optional[List[str]] = None @@ -127,3 +131,12 @@ class MCPServer(BaseModel): return any(h.lower() in auth_header_names for h in self.extra_headers) return False + + @property + def has_token_exchange_config(self) -> bool: + """True if this server is configured for OAuth2 token exchange (OBO / RFC 8693).""" + return ( + self.auth_type == MCPAuth.oauth2_token_exchange + and bool(self.client_id and self.client_secret) + and bool(self.token_exchange_endpoint or self.token_url) + ) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 00a7748309..400edcac88 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -825,6 +825,7 @@ API_ROUTE_TO_CALL_TYPES = { # Realtime API "/realtime": [CallTypes.arealtime], "/v1/realtime": [CallTypes.arealtime], + "/openai/v1/realtime": [CallTypes.arealtime], # Provider-specific routes "/anthropic/v1/messages": [CallTypes.anthropic_messages], # Google GenAI routes diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9c085c2c99..e66ad8e0cf 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27192,6 +27192,20 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.6-plus": { + "input_cost_per_token": 3.25e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.95e-06, + "source": "https://openrouter.ai/qwen/qwen3.6-plus", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/qwen/qwen3.5-35b-a3b": { "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", @@ -28879,6 +28893,19 @@ "mode": "chat", "output_cost_per_token": 0.0 }, + "sambanova/MiniMax-M2.7": { + "input_cost_per_token": 3e-07, + "litellm_provider": "sambanova", + "max_input_tokens": 204800, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.sambanova.ai/plans/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "sambanova/DeepSeek-R1": { "input_cost_per_token": 5e-06, "litellm_provider": "sambanova", @@ -34932,6 +34959,48 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.3": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "xai", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.3-latest": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "input_cost_per_token": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "litellm_provider": "xai", + "max_input_tokens": 1000000, + "max_output_tokens": 1000000, + "max_tokens": 1000000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 5e-06, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", diff --git a/pyproject.toml b/pyproject.toml index 7ff388f184..5cd83148d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.84.0" +version = "1.85.0" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -10,18 +10,22 @@ authors = [ { name = "BerriAI" }, ] dependencies = [ - "fastuuid==0.14.0", - "httpx==0.28.1", - "openai==2.33.0", - "python-dotenv==1.2.2", - "tiktoken==0.12.0", - "importlib-metadata==8.5.0", - "tokenizers==0.23.1", - "click==8.1.8", - "jinja2==3.1.6", - "aiohttp==3.13.4", - "pydantic==2.12.5", - "jsonschema==4.23.0", + # Ranges (not exact pins) so SDK consumers can coexist with their other + # deps. Reproducibility for our Docker/CI comes from `uv.lock`. + # When changing a floor, verify it installs + imports on every supported + # Python with: `uv pip install --resolution=lowest-direct .` + "fastuuid>=0.14.0,<1.0", + "httpx>=0.28.0,<1.0", + "openai>=2.20.0,<3.0.0", + "python-dotenv>=1.0.0,<2.0", + "tiktoken>=0.8.0,<1.0", + "importlib-metadata>=8.0.0,<9.0", + "tokenizers>=0.21.0,<1.0", + "click>=8.0.0,<9.0", + "jinja2>=3.1.6,<4.0", + "aiohttp>=3.10,<4.0", + "pydantic>=2.10.0,<3.0.0", + "jsonschema>=4.0.0,<5.0", ] [project.urls] @@ -246,7 +250,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.84.0" +version = "1.85.0" version_files = [ "pyproject.toml:^version", ] diff --git a/scripts/mock_bedrock_passthrough_target.py b/scripts/mock_bedrock_passthrough_target.py new file mode 100644 index 0000000000..e993cd99bd --- /dev/null +++ b/scripts/mock_bedrock_passthrough_target.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Minimal HTTP target for testing LiteLLM **Bedrock pass-through** (`/bedrock/...` on the proxy). + +What it does + - Serves a tiny Converse-shaped JSON (and optional invoke-shaped) response so the proxy can + complete a round trip without calling AWS. + - Does **not** verify SigV4 (Bedrock does); any Authorization header is accepted. + +How to run + uv run python scripts/mock_bedrock_passthrough_target.py --host 127.0.0.1 --port 9999 + +Wire LiteLLM to this host (use **one** of these patterns): + + 1) model_list (recommended) — set the Bedrock runtime base to the mock: + + model_list: + - model_name: mock-bedrock-claude + litellm_params: + model: bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0 + custom_llm_provider: bedrock + aws_region_name: us-west-2 + api_base: "http://127.0.0.1:9999" + + 2) Environment (see litellm BaseAWSLLM.get_runtime_endpoint):: + + export AWS_BEDROCK_RUNTIME_ENDPOINT="http://127.0.0.1:9999" + +Then call the proxy, e.g. (model_name must match config):: + + curl -sS -X POST "http://127.0.0.1:4000/bedrock/model/mock-bedrock-claude/converse" \ + -H "Authorization: Bearer $LITELLM_KEY" -H "Content-Type: application/json" \ + -d '{"messages":[{"role":"user","content":[{"text":"hi"}]}]}' + +The proxy will forward to: {api_base}/model//converse (SigV4-signed). +This mock implements POST .../converse and returns a minimal valid Converse response. + +Notes + - `invoke-with-response-stream` returns a real **binary** AWS event stream + (`application/vnd.amazon.eventstream`) with Anthropic-style JSON payloads inside each + `PayloadPart`, matching Bedrock's InvokeModelWithResponseStream wire format. See + https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModelWithResponseStream.html + and https://docs.aws.amazon.com/awstreams/latest/devguide/message-formats.html + - `converse-stream` is still JSON-only placeholder (different inner event shapes). + - Use real (or any non-empty) AWS creds in the environment of the **proxy**; signing still runs. +""" +from __future__ import annotations + +import argparse +import base64 +import json +from binascii import crc32 +from struct import pack +from typing import Any, Dict, Iterator, List + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.responses import StreamingResponse + +app = FastAPI(title="Mock Bedrock runtime (pass-through test target)") + + +# Minimal structure compatible with Converse: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Converse.html +def _converse_response_body() -> Dict[str, Any]: + return { + "output": { + "message": { + "role": "assistant", + "content": [ + {"text": "mock: ok from mock_bedrock_passthrough_target.py"} + ], + } + }, + "stopReason": "end_turn", + "usage": { + "inputTokens": 1, + "outputTokens": 2, + "totalTokens": 3, + }, + } + + +# Minimal invoke (Anthropic messages on bedrock) style — adjust if you test /invoke +def _invoke_response_body() -> Dict[str, Any]: + return { + "id": "msg_mock", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "mock invoke response"}], + "model": "mock", + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + } + + +def _encode_event_stream_message(headers: Dict[str, str], payload: bytes) -> bytes: + """Single AWS binary event-stream frame (same layout botocore's ``EventStreamBuffer`` parses).""" + header_blob = b"" + for name, value in headers.items(): + nb = name.encode("utf-8") + vb = value.encode("utf-8") + header_blob += bytes([len(nb)]) + nb + bytes([7]) + pack("!H", len(vb)) + vb + headers_length = len(header_blob) + payload_length = len(payload) + total_length = 12 + headers_length + payload_length + 4 + prelude_wo_crc = pack("!II", total_length, headers_length) + prelude_crc_val = crc32(prelude_wo_crc) & 0xFFFFFFFF + prelude = prelude_wo_crc + pack("!I", prelude_crc_val) + wo_msg_crc = prelude + header_blob + payload + msg_crc_val = crc32(wo_msg_crc[8:], prelude_crc_val) & 0xFFFFFFFF + return wo_msg_crc + pack("!I", msg_crc_val) + + +def _bedrock_payload_part(inner_event: Dict[str, Any]) -> bytes: + """Outer JSON expected by bedrock-runtime ``ResponseStream`` / ``PayloadPart``.""" + inner_bytes = json.dumps(inner_event, separators=(",", ":")).encode("utf-8") + outer = { + "chunk": { + "bytes": base64.b64encode(inner_bytes).decode("ascii"), + } + } + return json.dumps(outer, separators=(",", ":")).encode("utf-8") + + +def _anthropic_invoke_stream_events( + model_id: str, assistant_text: str +) -> List[Dict[str, Any]]: + """ + Minimal Anthropic Messages stream events as returned inside Bedrock stream chunks. + Mirrors the sequence Amazon emits for Claude on ``invoke-with-response-stream``. + """ + msg_id = "msg_mock_bedrock_stream" + input_tokens = 3 + output_tokens = max(1, len(assistant_text) // 4) + events: List[Dict[str, Any]] = [ + { + "type": "message_start", + "message": { + "model": model_id, + "id": msg_id, + "type": "message", + "role": "assistant", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": { + "input_tokens": input_tokens, + "output_tokens": 1, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0, + }, + }, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ] + # Split text into small deltas so downstream streaming behavior is visible. + step = 24 + for i in range(0, len(assistant_text), step): + events.append( + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": assistant_text[i : i + step], + }, + } + ) + events.append({"type": "content_block_stop", "index": 0}) + events.append( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + }, + } + ) + events.append( + { + "type": "message_stop", + "amazon-bedrock-invocationMetrics": { + "inputTokenCount": input_tokens, + "outputTokenCount": output_tokens, + "invocationLatency": 42, + "firstByteLatency": 10, + }, + } + ) + return events + + +def _iter_invoke_with_response_stream(model_id: str) -> Iterator[bytes]: + text = ( + "mock streaming: ok from scripts/mock_bedrock_passthrough_target.py " + "(invoke-with-response-stream)." + ) + headers = { + ":event-type": "chunk", + ":content-type": "application/json", + ":message-type": "event", + } + for ev in _anthropic_invoke_stream_events(model_id, text): + yield _encode_event_stream_message(headers, _bedrock_payload_part(ev)) + + +@app.get("/health") +def health() -> Dict[str, str]: + return {"status": "ok"} + + +@app.post("/model/{model_path:path}/converse") +async def converse(model_path: str, request: Request) -> JSONResponse: + # Optional: log body for debugging + _ = await request.body() + return JSONResponse(content=_converse_response_body()) + + +@app.post("/model/{model_path:path}/converse-stream") +async def converse_stream(model_path: str, request: Request) -> JSONResponse: + """ + Not a real AWS event stream — returns JSON for quick smoke tests only. + """ + _ = await request.body() + return JSONResponse( + content={ + "note": "This mock does not implement application/vnd.amazon.eventstream; use /converse for basic tests." + } + ) + + +@app.post("/model/{model_path:path}/invoke") +async def invoke(model_path: str, request: Request) -> JSONResponse: + _ = await request.body() + return JSONResponse(content=_invoke_response_body()) + + +@app.post("/model/{model_path:path}/invoke-with-response-stream") +async def invoke_with_response_stream( + model_path: str, request: Request +) -> StreamingResponse: + """ + Binary ``application/vnd.amazon.eventstream`` body compatible with boto3/botocore + ``InvokeModelWithResponseStream`` / LiteLLM's Bedrock invoke streaming path. + """ + _ = await request.body() + return StreamingResponse( + _iter_invoke_with_response_stream(model_id=model_path), + media_type="application/vnd.amazon.eventstream", + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9999) + args = parser.parse_args() + + import uvicorn + + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index 73a5635ab6..b2c7eeb78d 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -7,7 +7,9 @@ from __future__ import annotations import atexit import hashlib +import json import os +import re import sys from typing import Iterable @@ -74,6 +76,17 @@ FILTERED_RESPONSE_HEADERS = ( "date", ) +# Tiny placeholder used to replace base64 image payloads in cassettes. +# Decodes to b"test" — short, valid base64 so test code that decodes +# the field still succeeds. +VCR_IMAGE_B64_PLACEHOLDER = "dGVzdA==" + +# Fixed boundary substituted into multipart request bodies so the +# ``safe_body`` matcher sees the same bytes across record and replay. +# httpx generates a fresh random boundary per request via os.urandom, +# which otherwise turns every multipart cassette into a permanent miss. +VCR_FIXED_MULTIPART_BOUNDARY = "vcr-static-boundary" + def _scrub_response(response): if not isinstance(response, dict): @@ -86,8 +99,88 @@ def _scrub_response(response): return response +def _replace_b64_json_in_place(obj) -> bool: + """Recursively replace ``b64_json`` string values in a JSON tree. + + Returns ``True`` if any value was rewritten. The check on the + existing value's length keeps the function idempotent — once a + value has been swapped to the placeholder, subsequent invocations + are no-ops. + """ + changed = False + if isinstance(obj, dict): + for key, value in obj.items(): + if ( + key == "b64_json" + and isinstance(value, str) + and len(value) > len(VCR_IMAGE_B64_PLACEHOLDER) + ): + obj[key] = VCR_IMAGE_B64_PLACEHOLDER + changed = True + elif _replace_b64_json_in_place(value): + changed = True + elif isinstance(obj, list): + for item in obj: + if _replace_b64_json_in_place(item): + changed = True + return changed + + +def _strip_image_b64_payloads(response): + """Replace ``b64_json`` payloads in image-gen responses before save. + + Image-edit and image-generation responses carry the full base64 + PNG/JPEG (1-10+ MB) in ``data[*].b64_json``. The image_gen tests + only assert response shape — the field decodes, schema validates — + they never inspect pixel content. Swapping to a 4-byte placeholder + preserves all those checks while shrinking cassettes by ~99%. + """ + if not isinstance(response, dict): + return response + body = response.get("body") + if not isinstance(body, dict): + return response + raw = body.get("string") + if raw is None: + return response + + if isinstance(raw, (bytes, bytearray)): + try: + text = bytes(raw).decode("utf-8") + except UnicodeDecodeError: + return response + was_bytes = True + elif isinstance(raw, str): + text = raw + was_bytes = False + else: + return response + + try: + payload = json.loads(text) + except (ValueError, TypeError): + return response + + if not _replace_b64_json_in_place(payload): + return response + + new_text = json.dumps(payload, separators=(",", ":")) + body["string"] = new_text.encode("utf-8") if was_bytes else new_text + + headers = response.get("headers") + if isinstance(headers, dict): + new_len_value = str(len(new_text.encode("utf-8"))) + for key in list(headers): + if str(key).lower() == "content-length": + value = headers[key] + headers[key] = ( + [new_len_value] if isinstance(value, list) else new_len_value + ) + return response + + def _before_record_response(response): - return filter_non_2xx_response(_scrub_response(response)) + return filter_non_2xx_response(_scrub_response(_strip_image_b64_payloads(response))) def _safe_body_matcher(r1, r2) -> None: @@ -172,8 +265,84 @@ def _strip_headers(headers, names: Iterable[str]) -> None: pass +def _normalize_multipart_boundary(request) -> None: + """Rewrite random multipart boundaries to a fixed string in-place. + + httpx generates a fresh ``boundary=`` for every + multipart request via ``os.urandom``. Without normalization, the + request body bytes differ across runs even when everything else is + identical, the ``safe_body`` matcher misses, and the persister + keeps appending new episodes until ``MAX_EPISODES_PER_CASSETTE`` + refuses the save — leaving audio-transcription tests effectively + unmocked. Replacing the boundary in both the Content-Type header + and the body bytes makes the request deterministic. + + Idempotent — vcrpy invokes this hook multiple times per request, + so the second invocation sees ``boundary=vcr-static-boundary`` + already and short-circuits. + """ + headers = getattr(request, "headers", None) + if headers is None: + return + + content_type_key = None + content_type_value = None + try: + for key in list(headers.keys()): + if str(key).lower() == "content-type": + content_type_key = key + value = headers[key] + content_type_value = value if isinstance(value, str) else str(value) + break + except AttributeError: + return + + if not content_type_value or "multipart/" not in content_type_value.lower(): + return + + fixed_param = f"boundary={VCR_FIXED_MULTIPART_BOUNDARY}" + if fixed_param in content_type_value: + return + + match = re.search(r"boundary=([^\s;]+)", content_type_value) + if not match: + return + current_boundary = match.group(1).strip('"') + if current_boundary == VCR_FIXED_MULTIPART_BOUNDARY: + return + + try: + headers[content_type_key] = content_type_value.replace( + match.group(0), fixed_param + ) + except (TypeError, AttributeError): + return + + body = getattr(request, "body", None) + if body is None: + return + + if isinstance(body, (bytes, bytearray)): + try: + new_body = bytes(body).replace( + current_boundary.encode("utf-8"), + VCR_FIXED_MULTIPART_BOUNDARY.encode("utf-8"), + ) + except (TypeError, ValueError): + return + elif isinstance(body, str): + new_body = body.replace(current_boundary, VCR_FIXED_MULTIPART_BOUNDARY) + else: + return + + try: + request.body = new_body + except (AttributeError, TypeError): + pass + + def _before_record_request(request): - """Fingerprint API keys, then scrub them. + """Fingerprint API keys, scrub them, and normalize multipart boundaries. Order matters in two ways: @@ -187,7 +356,8 @@ def _before_record_request(request): auth headers we already stripped, so re-hashing would yield ``"no-key"`` and the stored vs. incoming fingerprints would diverge. Skip the recompute when the header is already set so - this hook is idempotent. + this hook is idempotent. The boundary normalizer is also + idempotent for the same reason. """ headers = getattr(request, "headers", None) if headers is None: @@ -199,6 +369,7 @@ def _before_record_request(request): except (TypeError, AttributeError): pass _strip_headers(headers, FILTERED_REQUEST_HEADERS) + _normalize_multipart_boundary(request) return request diff --git a/tests/batches_tests/conftest.py b/tests/batches_tests/conftest.py index e6e31546a8..ecb606b2cf 100644 --- a/tests/batches_tests/conftest.py +++ b/tests/batches_tests/conftest.py @@ -1,7 +1,6 @@ # conftest.py import asyncio -import importlib import os import sys @@ -12,16 +11,6 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm # noqa: E402,F401 -from tests._vcr_conftest_common import ( # noqa: E402 - VerboseReporterState, - apply_vcr_auto_marker_to_items, - record_vcr_outcome, - register_persister_if_enabled, - vcr_config_dict, -) - -_verbose_state = VerboseReporterState() - @pytest.fixture(scope="session") def event_loop(): @@ -31,37 +20,3 @@ def event_loop(): loop = asyncio.new_event_loop() yield loop loop.close() - - -@pytest.fixture(scope="module") -def vcr_config(): - return vcr_config_dict() - - -def pytest_recording_configure(config, vcr): - register_persister_if_enabled(vcr) - - -@pytest.hookimpl(hookwrapper=True) -def pytest_runtest_makereport(item, call): - outcome = yield - rep = outcome.get_result() - setattr(item, f"rep_{rep.when}", rep) - - -@pytest.fixture(autouse=True) -def _vcr_outcome_gate(request, vcr): - yield - record_vcr_outcome(request, vcr) - - -def pytest_configure(config): - _verbose_state.remember_pluginmanager(config) - - -def pytest_runtest_logreport(report): - _verbose_state.maybe_emit_verdict(report) - - -def pytest_collection_modifyitems(config, items): - apply_vcr_auto_marker_to_items(items) diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index 1c1af308df..c489685eaf 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -47,6 +47,37 @@ class TestCustomLogger(CustomLogger): self.standard_logging_object = kwargs["standard_logging_object"] +async def _acreate_fine_tuning_job_with_propagation_retry( + *, max_attempts: int = 12, initial_delay: float = 1.0, **kwargs +): + """ + Wrap litellm.acreate_fine_tuning_job and retry on the eventual-consistency + 400 OpenAI returns when a freshly-uploaded training file isn't yet visible + to the fine-tuning endpoint (`'file-... does not exist'`). + + Polling the files-retrieve endpoint or `FileObject.status` doesn't help — + OpenAI's `status` field is deprecated, and the retrieve and fine-tuning + endpoints don't share a consistency model. Retrying the operation itself + is the only reliable signal that propagation has finished. + + Total budget with defaults: ~70s across 12 attempts (exp backoff capped at + 8s). + """ + delay = initial_delay + last_error: Optional[openai.BadRequestError] = None + for _ in range(max_attempts): + try: + return await litellm.acreate_fine_tuning_job(**kwargs) + except openai.BadRequestError as e: + if "does not exist" not in str(e): + raise + last_error = e + await asyncio.sleep(delay) + delay = min(delay * 1.5, 8.0) + assert last_error is not None + raise last_error + + @pytest.mark.asyncio async def test_create_fine_tune_jobs_async(): try: @@ -64,9 +95,11 @@ async def test_create_fine_tune_jobs_async(): ) print("Response from creating file=", file_obj) - create_fine_tuning_response = await litellm.acreate_fine_tuning_job( - model="gpt-3.5-turbo-0125", - training_file=file_obj.id, + create_fine_tuning_response = ( + await _acreate_fine_tuning_job_with_propagation_retry( + model="gpt-4o-mini-2024-07-18", + training_file=file_obj.id, + ) ) print( @@ -74,7 +107,7 @@ async def test_create_fine_tune_jobs_async(): ) assert create_fine_tuning_response.id is not None - assert create_fine_tuning_response.model == "gpt-3.5-turbo-0125" + assert create_fine_tuning_response.model == "gpt-4o-mini-2024-07-18" await asyncio.sleep(2) _logged_standard_logging_object = custom_logger.standard_logging_object @@ -83,7 +116,7 @@ async def test_create_fine_tune_jobs_async(): "custom_logger.standard_logging_object=", json.dumps(_logged_standard_logging_object, indent=4), ) - assert _logged_standard_logging_object["model"] == "gpt-3.5-turbo-0125" + assert _logged_standard_logging_object["model"] == "gpt-4o-mini-2024-07-18" assert _logged_standard_logging_object["id"] == create_fine_tuning_response.id # list fine tuning jobs @@ -427,10 +460,10 @@ async def test_mock_openai_create_fine_tune_job(): with patch.object(client.fine_tuning.jobs, "create") as mock_create: mock_create.return_value = FineTuningJob( id="ft-123", - model="gpt-3.5-turbo-0125", + model="gpt-4o-mini-2024-07-18", created_at=1677610602, status="validating_files", - fine_tuned_model="ft:gpt-3.5-turbo-0125:org:custom_suffix:id", + fine_tuned_model="ft:gpt-4o-mini-2024-07-18:org:custom_suffix:id", object="fine_tuning.job", hyperparameters=Hyperparameters( n_epochs=3, @@ -442,7 +475,7 @@ async def test_mock_openai_create_fine_tune_job(): ) response = await litellm.acreate_fine_tuning_job( - model="gpt-3.5-turbo-0125", + model="gpt-4o-mini-2024-07-18", training_file="file-123", hyperparameters={"n_epochs": 3}, suffix="custom_suffix", @@ -453,16 +486,19 @@ async def test_mock_openai_create_fine_tune_job(): mock_create.assert_called_once() request_params = mock_create.call_args.kwargs - assert request_params["model"] == "gpt-3.5-turbo-0125" + assert request_params["model"] == "gpt-4o-mini-2024-07-18" assert request_params["training_file"] == "file-123" assert request_params["hyperparameters"] == {"n_epochs": 3} assert request_params["suffix"] == "custom_suffix" # Verify the response assert response.id == "ft-123" - assert response.model == "gpt-3.5-turbo-0125" + assert response.model == "gpt-4o-mini-2024-07-18" assert response.status == "validating_files" - assert response.fine_tuned_model == "ft:gpt-3.5-turbo-0125:org:custom_suffix:id" + assert ( + response.fine_tuned_model + == "ft:gpt-4o-mini-2024-07-18:org:custom_suffix:id" + ) @pytest.mark.asyncio diff --git a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py index 9f879b9d50..e6c76fa7cd 100644 --- a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py +++ b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py @@ -661,14 +661,14 @@ async def test_async_log_failure_event(prometheus_logger): # litellm_llm_api_failed_requests_metric incremented # Labels: end_user, hashed_api_key, api_key_alias, model, team, team_alias, user, model_id prometheus_logger.litellm_llm_api_failed_requests_metric.labels.assert_called_once_with( - None, # end_user_id - "test_hash", - "test_alias", - "gpt-3.5-turbo", - "test_team", - "test_team_alias", - "test_user", - "model-123", # model_id from standard_logging_payload + end_user=None, + hashed_api_key="test_hash", + api_key_alias="test_alias", + model="gpt-3.5-turbo", + team="test_team", + team_alias="test_team_alias", + user="test_user", + model_id="model-123", ) prometheus_logger.litellm_llm_api_failed_requests_metric.labels().inc.assert_called_once() diff --git a/tests/litellm/test_sambanova_model_metadata.py b/tests/litellm/test_sambanova_model_metadata.py new file mode 100644 index 0000000000..bc31bfb0af --- /dev/null +++ b/tests/litellm/test_sambanova_model_metadata.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + +def test_sambanova_minimax_m27_model_info(): + model = "sambanova/MiniMax-M2.7" + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + info = model_cost.get(model) + assert ( + info is not None + ), f"{model} not found in model_prices_and_context_window.json" + assert info["litellm_provider"] == "sambanova" + assert info["mode"] == "chat" + assert info["input_cost_per_token"] > 0 + assert info["output_cost_per_token"] > 0 + assert info["max_input_tokens"] == 204800 + assert info["max_output_tokens"] == 131072 + assert info["supports_function_calling"] is True + assert info["supports_reasoning"] is True + assert info["supports_tool_choice"] is True + + routed_model, provider, _, _ = get_llm_provider(model=model) + assert routed_model == "MiniMax-M2.7" + assert provider == "sambanova" diff --git a/tests/litellm_utils_tests/test_proxy_budget_reset.py b/tests/litellm_utils_tests/test_proxy_budget_reset.py index a64c6c7aa3..6240bedd3e 100644 --- a/tests/litellm_utils_tests/test_proxy_budget_reset.py +++ b/tests/litellm_utils_tests/test_proxy_budget_reset.py @@ -233,6 +233,12 @@ async def test_reset_budget_endusers_partial_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -400,6 +406,12 @@ async def test_reset_budget_continues_other_categories_on_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -884,6 +896,12 @@ async def test_service_logger_endusers_success(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -966,6 +984,12 @@ async def test_service_logger_endusers_failure(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + # Mock db.litellm_organizationtable.update_many (used by reset_budget_for_orgs_linked_to_budgets) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + # Mock db.litellm_tagtable.update_many (used by reset_budget_for_tags_linked_to_budgets) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -1060,6 +1084,10 @@ async def test_reset_budget_for_litellm_team_members_called(): prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( return_value={"count": 0} ) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 0} + ) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 0}) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() diff --git a/tests/llm_translation/base_llm_unit_tests.py b/tests/llm_translation/base_llm_unit_tests.py index aedc4f810c..77850dac45 100644 --- a/tests/llm_translation/base_llm_unit_tests.py +++ b/tests/llm_translation/base_llm_unit_tests.py @@ -853,7 +853,11 @@ class BaseLLMChatTest(ABC): @pytest.mark.parametrize( "image_url", [ - "http://img1.etsystatic.com/260/0/7813604/il_fullxfull.4226713999_q86e.jpg", + # In-repo logo served via jsdelivr (sha-pinned, immutable). + # Bedrock fetches the URL and base64-embeds it in the + # Converse request body; using a multi-MB hosted product + # photo here previously bloated cassettes to ~60 MB each. + "https://cdn.jsdelivr.net/gh/BerriAI/litellm@d769e81c90d453240c61fc572cdb27fae06a89d0/ui/litellm-dashboard/public/assets/logos/litellm_logo.jpg", "https://awsmp-logos.s3.amazonaws.com/seller-xw5kijmvmzasy/c233c9ade2ccb5491072ae232c814942.png", ], ) diff --git a/tests/llm_translation/realtime/test_openai_realtime.py b/tests/llm_translation/realtime/test_openai_realtime.py index e08b2de7fe..c5f77de6be 100644 --- a/tests/llm_translation/realtime/test_openai_realtime.py +++ b/tests/llm_translation/realtime/test_openai_realtime.py @@ -101,7 +101,7 @@ async def test_openai_realtime_direct_call_no_intent(): try: await litellm._arealtime( - model="openai/gpt-4o-realtime-preview-2024-10-01", + model="openai/gpt-4o-realtime-preview", websocket=websocket_client, api_key=os.environ.get("OPENAI_API_KEY"), timeout=60, @@ -250,13 +250,13 @@ async def test_openai_realtime_direct_call_with_intent(): caught_exception = None query_params: RealtimeQueryParams = { - "model": "openai/gpt-4o-realtime-preview-2024-10-01", + "model": "openai/gpt-4o-realtime-preview", "intent": "chat", } try: await litellm._arealtime( - model="openai/gpt-4o-realtime-preview-2024-10-01", + model="openai/gpt-4o-realtime-preview", websocket=websocket_client, api_key=os.environ.get("OPENAI_API_KEY"), query_params=query_params, @@ -331,7 +331,7 @@ def test_realtime_query_params_construction(): from litellm.types.realtime import RealtimeQueryParams # Test case 1: intent is None (should not be included) - model = "gpt-4o-realtime-preview-2024-10-01" + model = "gpt-4o-realtime-preview" intent = None query_params: RealtimeQueryParams = {"model": model} @@ -369,17 +369,17 @@ async def test_realtime_query_params_use_normalized_model_name(monkeypatch): ) def fake_get_llm_provider(model, api_base=None, api_key=None): - return ("gpt-4o-realtime-preview-2024-10-01", "openai", None, None) + return ("gpt-4o-realtime-preview", "openai", None, None) monkeypatch.setattr(realtime_main, "get_llm_provider", fake_get_llm_provider) query_params: RealtimeQueryParams = { - "model": "openai/gpt-4o-realtime-preview-2024-10-01", + "model": "openai/gpt-4o-realtime-preview", "intent": "chat", } await realtime_main._arealtime( - model="openai/gpt-4o-realtime-preview-2024-10-01", + model="openai/gpt-4o-realtime-preview", websocket=MagicMock(), api_key="sk-test", query_params=query_params, @@ -387,7 +387,5 @@ async def test_realtime_query_params_use_normalized_model_name(monkeypatch): ) called_kwargs = mock_async_realtime.call_args.kwargs - assert ( - called_kwargs["query_params"]["model"] == "gpt-4o-realtime-preview-2024-10-01" - ) + assert called_kwargs["query_params"]["model"] == "gpt-4o-realtime-preview" assert called_kwargs["query_params"]["intent"] == "chat" diff --git a/tests/llm_translation/test_evals_api.py b/tests/llm_translation/test_evals_api.py index 8926300020..4a55663e66 100644 --- a/tests/llm_translation/test_evals_api.py +++ b/tests/llm_translation/test_evals_api.py @@ -2,6 +2,7 @@ Tests for Evals API operations across providers """ +import hashlib import os import sys from abc import ABC, abstractmethod @@ -20,6 +21,46 @@ from litellm.types.llms.openai_evals import ( ) +def _stable_eval_name(test_node_name: str, suffix: str = "") -> str: + """Deterministic eval name keyed off the test's node name. + + The previous ``f"Test Eval {int(time.time())}"`` pattern embedded a + fresh value into the request body every run, defeating VCR's + ``safe_body`` matcher and forcing a real OpenAI ``create`` call on + every CI run. With a stable per-test name the cassette matches on + replay, and provider-side resources stay bounded because each test + deletes the eval it owns on teardown. + """ + nonce = hashlib.sha1(test_node_name.encode()).hexdigest()[:12] + return f"vcr-managed-{nonce}{suffix}" + + +_TESTING_CRITERIA = [ + { + "type": "label_model", + "model": "gpt-4o", + "input": [ + { + "role": "developer", + "content": "Classify the sentiment as 'positive' or 'negative'", + }, + {"role": "user", "content": "Statement: {{item.input}}"}, + ], + "passing_labels": ["positive"], + "labels": ["positive", "negative"], + "name": "Sentiment grader", + } +] + + +_PROVIDER_FLAKINESS = ( + litellm.InternalServerError, + litellm.APIConnectionError, + litellm.Timeout, + litellm.ServiceUnavailableError, +) + + class BaseEvalsAPITest(ABC): """ Base test class for Evals API operations. @@ -41,13 +82,64 @@ class BaseEvalsAPITest(ABC): """Return the API base URL for the provider""" pass + @pytest.fixture + def managed_eval(self, request): + """Create a stable-named eval for this test; delete on teardown. + + Function-scoped so each cassette captures the full + create→test→delete cycle. A class-scoped fixture would push + the create into whichever test ran first and the delete into + whichever ran last, which is fragile under reordering. + + Replaces the prior ``list_evals().data[0].id`` pattern, which + made the URL of ``get_eval`` / ``update_eval`` vary across + runs (the "first" eval depends on what other runs left + behind). + """ + custom_llm_provider = self.get_custom_llm_provider() + api_key = self.get_api_key() + api_base = self.get_api_base() + + if not api_key: + pytest.skip(f"No API key provided for {custom_llm_provider}") + + try: + created = litellm.create_eval( + name=_stable_eval_name(request.node.name), + data_source_config={ + "type": "stored_completions", + "metadata": {"usecase": "chatbot", "vcr": "managed"}, + }, + testing_criteria=_TESTING_CRITERIA, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + api_base=api_base, + ) + except _PROVIDER_FLAKINESS: + pytest.skip("Provider service unavailable") + except litellm.RateLimitError: + pytest.skip("Rate limit exceeded") + + yield created + + # Best-effort cleanup. OpenAI eval names are not unique-keyed + # (only IDs are), so a failed delete doesn't block the next + # run's create. + try: + litellm.delete_eval( + eval_id=created.id, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + api_base=api_base, + ) + except Exception: + pass + @pytest.mark.flaky(retries=3, delay=2) - def test_create_eval(self): + def test_create_eval(self, request): """ Test creating an evaluation. """ - import time - custom_llm_provider = self.get_custom_llm_provider() api_key = self.get_api_key() api_base = self.get_api_base() @@ -56,53 +148,45 @@ class BaseEvalsAPITest(ABC): pytest.skip(f"No API key provided for {custom_llm_provider}") litellm.set_verbose = True + unique_name = _stable_eval_name(request.node.name) - # Create eval with stored_completions data source - unique_name = f"Test Eval {int(time.time())}" - + created_id = None try: - response = litellm.create_eval( - name=unique_name, - data_source_config={ - "type": "stored_completions", - "metadata": {"usecase": "chatbot"}, - }, - testing_criteria=[ - { - "type": "label_model", - "model": "gpt-4o", - "input": [ - { - "role": "developer", - "content": "Classify the sentiment as 'positive' or 'negative'", - }, - {"role": "user", "content": "Statement: {{item.input}}"}, - ], - "passing_labels": ["positive"], - "labels": ["positive", "negative"], - "name": "Sentiment grader", - } - ], - custom_llm_provider=custom_llm_provider, - api_key=api_key, - api_base=api_base, - ) - except ( - litellm.InternalServerError, - litellm.APIConnectionError, - litellm.Timeout, - litellm.ServiceUnavailableError, - ): - pytest.skip("Provider service unavailable") - except litellm.RateLimitError: - pytest.skip("Rate limit exceeded") + try: + response = litellm.create_eval( + name=unique_name, + data_source_config={ + "type": "stored_completions", + "metadata": {"usecase": "chatbot"}, + }, + testing_criteria=_TESTING_CRITERIA, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + api_base=api_base, + ) + except _PROVIDER_FLAKINESS: + pytest.skip("Provider service unavailable") + except litellm.RateLimitError: + pytest.skip("Rate limit exceeded") - assert response is not None - assert isinstance(response, Eval) - assert response.id is not None - assert response.name == unique_name - print(f"Created eval: {response}") - print(f"Eval ID: {response.id}") + assert response is not None + assert isinstance(response, Eval) + assert response.id is not None + assert response.name == unique_name + created_id = response.id + print(f"Created eval: {response}") + print(f"Eval ID: {response.id}") + finally: + if created_id is not None: + try: + litellm.delete_eval( + eval_id=created_id, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + api_base=api_base, + ) + except Exception: + pass def test_list_evals(self): """ @@ -130,7 +214,7 @@ class BaseEvalsAPITest(ABC): assert hasattr(response, "has_more") print(f"Listed evals: {len(response.data)} evaluations") - def test_get_eval(self): + def test_get_eval(self, managed_eval): """ Test getting a specific evaluation by ID. """ @@ -138,89 +222,54 @@ class BaseEvalsAPITest(ABC): api_key = self.get_api_key() api_base = self.get_api_base() - if not api_key: - pytest.skip(f"No API key provided for {custom_llm_provider}") - litellm.set_verbose = True - # First list existing evals to get an ID - list_response = litellm.list_evals( - limit=1, + response = litellm.get_eval( + eval_id=managed_eval.id, custom_llm_provider=custom_llm_provider, api_key=api_key, api_base=api_base, ) - assert isinstance(list_response, ListEvalsResponse) + assert response is not None + assert isinstance(response, Eval) + assert response.id == managed_eval.id + print(f"Retrieved eval: {response}") - if list_response.data and len(list_response.data) > 0: - eval_id = list_response.data[0].id - print(f"Testing with eval ID: {eval_id}") - - # Get the eval - response = litellm.get_eval( - eval_id=eval_id, - custom_llm_provider=custom_llm_provider, - api_key=api_key, - api_base=api_base, - ) - - assert response is not None - assert isinstance(response, Eval) - assert response.id == eval_id - print(f"Retrieved eval: {response}") - else: - pytest.skip("No existing evals to test with") - - def test_update_eval(self): + @pytest.mark.flaky(retries=3, delay=2) + def test_update_eval(self, request, managed_eval): """ Test updating an evaluation. """ - import time - custom_llm_provider = self.get_custom_llm_provider() api_key = self.get_api_key() api_base = self.get_api_base() - if not api_key: - pytest.skip(f"No API key provided for {custom_llm_provider}") - litellm.set_verbose = True + updated_name = _stable_eval_name(request.node.name, suffix="-updated") - # First list existing evals - list_response = litellm.list_evals( - limit=1, + response = litellm.update_eval( + eval_id=managed_eval.id, + name=updated_name, custom_llm_provider=custom_llm_provider, api_key=api_key, api_base=api_base, ) - assert isinstance(list_response, ListEvalsResponse) - - if list_response.data and len(list_response.data) > 0: - eval_id = list_response.data[0].id - updated_name = f"Updated Eval {int(time.time())}" - - # Update the eval - response = litellm.update_eval( - eval_id=eval_id, - name=updated_name, - custom_llm_provider=custom_llm_provider, - api_key=api_key, - api_base=api_base, - ) - - assert response is not None - assert isinstance(response, Eval) - assert response.id == eval_id - assert response.name == updated_name - print(f"Updated eval: {response}") - else: - pytest.skip("No existing evals to test with") + assert response is not None + assert isinstance(response, Eval) + assert response.id == managed_eval.id + assert response.name == updated_name + print(f"Updated eval: {response}") def test_delete_eval(self): """ Test deleting an evaluation. + + Real delete coverage now lives in the ``managed_eval`` fixture + teardown and in ``test_create_eval``'s ``finally`` block, so + this stays a no-op skip rather than creating a fresh resource + just to delete it. """ custom_llm_provider = self.get_custom_llm_provider() api_key = self.get_api_key() @@ -229,8 +278,7 @@ class BaseEvalsAPITest(ABC): if not api_key: pytest.skip(f"No API key provided for {custom_llm_provider}") - # Skip this test to avoid deleting production evals - pytest.skip("Skipping delete test to preserve existing evals") + pytest.skip("Delete is exercised via managed_eval fixture teardown.") class TestOpenAIEvalsAPI(BaseEvalsAPITest): diff --git a/tests/llm_translation/test_vcr_filters.py b/tests/llm_translation/test_vcr_filters.py new file mode 100644 index 0000000000..0389168278 --- /dev/null +++ b/tests/llm_translation/test_vcr_filters.py @@ -0,0 +1,220 @@ +"""Unit tests for the VCR record-time filters that keep cassettes small. + +Covers: +- ``_strip_image_b64_payloads`` — replaces base64 image bodies in + image-gen responses so cassettes don't carry MB-class PNG payloads. +- ``_normalize_multipart_boundary`` — rewrites random multipart + boundaries to a fixed string so audio-transcription request bodies + match across record and replay. +""" + +from __future__ import annotations + +import json +import os +import sys + +from vcr.request import Request + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from tests._vcr_conftest_common import ( # noqa: E402 + VCR_FIXED_MULTIPART_BOUNDARY, + VCR_IMAGE_B64_PLACEHOLDER, + _normalize_multipart_boundary, + _strip_image_b64_payloads, +) + + +# --------------------------------------------------------------------------- +# Image b64 stripper +# --------------------------------------------------------------------------- + + +def _image_response(b64_payload: str, body_type: str = "bytes") -> dict: + body_text = json.dumps({"data": [{"b64_json": b64_payload}]}) + body_string = body_text.encode("utf-8") if body_type == "bytes" else body_text + return { + "status": {"code": 200, "message": "OK"}, + "headers": { + "content-type": ["application/json"], + "content-length": [str(len(body_text.encode("utf-8")))], + }, + "body": {"string": body_string}, + } + + +def test_strip_image_b64_replaces_payload_when_body_is_bytes(): + response = _image_response("A" * 5000, body_type="bytes") + out = _strip_image_b64_payloads(response) + payload = json.loads(out["body"]["string"].decode("utf-8")) + assert payload["data"][0]["b64_json"] == VCR_IMAGE_B64_PLACEHOLDER + + +def test_strip_image_b64_replaces_payload_when_body_is_str(): + response = _image_response("A" * 5000, body_type="str") + out = _strip_image_b64_payloads(response) + payload = json.loads(out["body"]["string"]) + assert payload["data"][0]["b64_json"] == VCR_IMAGE_B64_PLACEHOLDER + + +def test_strip_image_b64_updates_content_length(): + response = _image_response("A" * 5000) + out = _strip_image_b64_payloads(response) + expected_len = len(out["body"]["string"]) + assert out["headers"]["content-length"] == [str(expected_len)] + + +def test_strip_image_b64_is_idempotent(): + response = _image_response("A" * 5000) + once = _strip_image_b64_payloads(response) + twice = _strip_image_b64_payloads(once) + assert once["body"]["string"] == twice["body"]["string"] + + +def test_strip_image_b64_handles_nested_data(): + body_text = json.dumps( + { + "outer": { + "data": [ + {"b64_json": "X" * 4000, "label": "first"}, + {"b64_json": "Y" * 4000, "label": "second"}, + ] + } + } + ) + response = { + "status": {"code": 200, "message": "OK"}, + "headers": {"content-type": ["application/json"]}, + "body": {"string": body_text.encode("utf-8")}, + } + out = _strip_image_b64_payloads(response) + payload = json.loads(out["body"]["string"].decode("utf-8")) + assert payload["outer"]["data"][0]["b64_json"] == VCR_IMAGE_B64_PLACEHOLDER + assert payload["outer"]["data"][1]["b64_json"] == VCR_IMAGE_B64_PLACEHOLDER + assert payload["outer"]["data"][0]["label"] == "first" + + +def test_strip_image_b64_leaves_non_image_response_unchanged(): + body_text = json.dumps({"choices": [{"message": {"content": "hello"}}]}) + response = { + "status": {"code": 200, "message": "OK"}, + "headers": {"content-type": ["application/json"]}, + "body": {"string": body_text.encode("utf-8")}, + } + out = _strip_image_b64_payloads(response) + assert json.loads(out["body"]["string"].decode("utf-8")) == json.loads(body_text) + + +def test_strip_image_b64_leaves_invalid_json_unchanged(): + response = { + "status": {"code": 200, "message": "OK"}, + "headers": {"content-type": ["application/octet-stream"]}, + "body": {"string": b"\x89PNG\r\n\x1a\n binary stuff not json"}, + } + out = _strip_image_b64_payloads(response) + assert out["body"]["string"] == b"\x89PNG\r\n\x1a\n binary stuff not json" + + +def test_strip_image_b64_skips_short_values(): + """Already-placeholder values aren't re-replaced (idempotency guard).""" + body_text = json.dumps({"data": [{"b64_json": VCR_IMAGE_B64_PLACEHOLDER}]}) + response = { + "status": {"code": 200, "message": "OK"}, + "headers": {"content-type": ["application/json"]}, + "body": {"string": body_text.encode("utf-8")}, + } + out = _strip_image_b64_payloads(response) + payload = json.loads(out["body"]["string"].decode("utf-8")) + assert payload["data"][0]["b64_json"] == VCR_IMAGE_B64_PLACEHOLDER + + +# --------------------------------------------------------------------------- +# Multipart boundary normalizer +# --------------------------------------------------------------------------- + + +def _multipart_request(boundary: str): + body_text = ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n' + "Content-Type: audio/wav\r\n" + "\r\n" + "fake-audio-bytes\r\n" + f"--{boundary}--\r\n" + ) + return Request( + method="POST", + uri="https://api.openai.com/v1/audio/transcriptions", + body=body_text.encode("utf-8"), + headers={ + "content-type": f"multipart/form-data; boundary={boundary}", + }, + ) + + +def test_normalize_multipart_rewrites_header_and_body(): + req = _multipart_request("abc123random") + _normalize_multipart_boundary(req) + assert ( + req.headers["content-type"] + == f"multipart/form-data; boundary={VCR_FIXED_MULTIPART_BOUNDARY}" + ) + assert b"abc123random" not in req.body + assert VCR_FIXED_MULTIPART_BOUNDARY.encode("utf-8") in req.body + + +def test_normalize_multipart_is_idempotent(): + req = _multipart_request("abc123random") + _normalize_multipart_boundary(req) + body_first = req.body + header_first = req.headers["content-type"] + _normalize_multipart_boundary(req) + assert req.body == body_first + assert req.headers["content-type"] == header_first + + +def test_normalize_multipart_two_distinct_boundaries_match_after_normalize(): + """Whisper-style: two requests with different random boundaries should + end up with byte-identical bodies after normalization.""" + req1 = _multipart_request("boundaryAAA") + req2 = _multipart_request("boundaryBBB") + _normalize_multipart_boundary(req1) + _normalize_multipart_boundary(req2) + assert req1.body == req2.body + assert req1.headers["content-type"] == req2.headers["content-type"] + + +def test_normalize_multipart_skips_non_multipart_requests(): + req = Request( + method="POST", + uri="https://api.openai.com/v1/chat/completions", + body=b'{"model":"gpt-4o"}', + headers={"content-type": "application/json"}, + ) + _normalize_multipart_boundary(req) + assert req.headers["content-type"] == "application/json" + assert req.body == b'{"model":"gpt-4o"}' + + +def test_normalize_multipart_skips_request_without_content_type(): + req = Request( + method="POST", + uri="https://api.openai.com/v1/chat/completions", + body=b"unknown body", + headers={}, + ) + _normalize_multipart_boundary(req) + assert req.body == b"unknown body" + + +def test_normalize_multipart_handles_quoted_boundary(): + req = Request( + method="POST", + uri="https://api.openai.com/v1/audio/transcriptions", + body=b"--quoted-boundary--body content--quoted-boundary--", + headers={"content-type": 'multipart/form-data; boundary="quoted-boundary"'}, + ) + _normalize_multipart_boundary(req) + assert b"quoted-boundary" not in req.body + assert VCR_FIXED_MULTIPART_BOUNDARY.encode("utf-8") in req.body diff --git a/tests/logging_callback_tests/conftest.py b/tests/logging_callback_tests/conftest.py index 4f847d93a5..7042d6094d 100644 --- a/tests/logging_callback_tests/conftest.py +++ b/tests/logging_callback_tests/conftest.py @@ -101,6 +101,7 @@ _SCALAR_ATTRS = ( "redact_messages_in_exceptions", "redact_user_api_key_info", "s3_callback_params", + "s3_audit_callback_params", "datadog_params", "vector_store_registry", ) @@ -128,6 +129,7 @@ def isolate_litellm_state(): leaking across tests within the same xdist worker. """ from litellm.litellm_core_utils import litellm_logging as ll_logging + from litellm.proxy.management_helpers import audit_logs as ll_audit_logs # Flush cache and clear internal logger instances before test if hasattr(litellm, "in_memory_llm_clients_cache"): @@ -135,6 +137,7 @@ def isolate_litellm_state(): # Clear cached logger instances (LangsmithLogger, SlackAlerting, etc.) ll_logging._in_memory_loggers.clear() + ll_audit_logs._audit_log_callback_cache.clear() # Reset ALL attrs to their true defaults before the test runs. # This undoes any module-level mutations from test file imports. @@ -156,6 +159,7 @@ def isolate_litellm_state(): litellm.in_memory_llm_clients_cache.flush_cache() ll_logging._in_memory_loggers.clear() + ll_audit_logs._audit_log_callback_cache.clear() for attr in _LIST_ATTRS: if attr in _DEFAULTS: diff --git a/tests/ocr_tests/base_ocr_unit_tests.py b/tests/ocr_tests/base_ocr_unit_tests.py index 2120abc8a0..ae65efd952 100644 --- a/tests/ocr_tests/base_ocr_unit_tests.py +++ b/tests/ocr_tests/base_ocr_unit_tests.py @@ -12,7 +12,15 @@ from abc import ABC, abstractmethod # Test resources TEST_IMAGE_PATH = "test_image_edit.png" -TEST_PDF_URL = "https://arxiv.org/pdf/2201.04234" +# Tiny in-repo PDF served via jsdelivr (sha-pinned, immutable). The arxiv +# PDF previously used here was several MB — once base64-encoded into the +# Vertex OCR request it ballooned cassettes past 100 MB per test. Keep +# the URL stable across runs so cassettes don't churn. +TEST_PDF_URL = ( + "https://cdn.jsdelivr.net/gh/BerriAI/litellm" + "@d769e81c90d453240c61fc572cdb27fae06a89d0" + "/tests/llm_translation/fixtures/dummy.pdf" +) class BaseOCRTest(ABC): diff --git a/tests/otel_tests/test_e2e_budgeting.py b/tests/otel_tests/test_e2e_budgeting.py index 62fc8732eb..f61befac4f 100644 --- a/tests/otel_tests/test_e2e_budgeting.py +++ b/tests/otel_tests/test_e2e_budgeting.py @@ -25,8 +25,8 @@ async def make_calls_until_budget_exceeded(session, key: str, call_function, **k # Check error structure and values that should be consistent assert ( - error_dict["code"] == "400" - ), f"Expected error code 400, got: {error_dict['code']}" + error_dict["code"] == "429" + ), f"Expected error code 429, got: {error_dict['code']}" assert ( error_dict["type"] == "budget_exceeded" ), f"Expected error type budget_exceeded, got: {error_dict['type']}" diff --git a/tests/otel_tests/test_e2e_model_access.py b/tests/otel_tests/test_e2e_model_access.py index 7ea75a9d61..87d85a1960 100644 --- a/tests/otel_tests/test_e2e_model_access.py +++ b/tests/otel_tests/test_e2e_model_access.py @@ -99,7 +99,7 @@ async def test_model_access_patterns(key_models, test_model, expect_success): # Assert error structure and values assert _error_body["type"] == "key_model_access_denied" assert _error_body["param"] == "model" - assert _error_body["code"] == "401" + assert _error_body["code"] == "403" assert "key not allowed to access model" in _error_body["message"] @@ -297,7 +297,7 @@ def _validate_model_access_exception( # Assert error structure and values assert _error_body["type"] == expected_type assert _error_body["param"] == "model" - assert _error_body["code"] == "401" + assert _error_body["code"] == "403" if expected_type == "key_model_access_denied": assert "key not allowed to access model" in _error_body["message"] elif expected_type == "team_model_access_denied": diff --git a/tests/proxy_unit_tests/test_request_size_limit_middleware.py b/tests/proxy_unit_tests/test_request_size_limit_middleware.py new file mode 100644 index 0000000000..3e8792e017 --- /dev/null +++ b/tests/proxy_unit_tests/test_request_size_limit_middleware.py @@ -0,0 +1,135 @@ +import pytest +from starlette.responses import JSONResponse +from starlette.testclient import TestClient +from starlette.types import Message + +from litellm.proxy.middleware.request_size_limit_middleware import ( + RequestSizeLimitMiddleware, +) + + +def test_request_size_limit_middleware_rejects_content_length_before_body_read(): + downstream_called = False + + async def app(scope, receive, send): + nonlocal downstream_called + downstream_called = True + response = JSONResponse({"ok": True}) + await response(scope, receive, send) + + client = TestClient( + RequestSizeLimitMiddleware( + app, + get_max_request_size_mb=lambda: 1, + is_request_size_limit_enabled=lambda: True, + ) + ) + + response = client.post( + "/chat/completions", + content=b"x" * (1024 * 1024 + 1), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 413 + assert response.json() == {"error": "Request size is too large. Max size is 1 MB"} + assert response.headers["content-length"] == str(len(response.content)) + assert downstream_called is False + + +def test_request_size_limit_middleware_zero_limit_disables_guard(): + downstream_called = False + + async def app(scope, receive, send): + nonlocal downstream_called + downstream_called = True + response = JSONResponse({"ok": True}) + await response(scope, receive, send) + + client = TestClient( + RequestSizeLimitMiddleware( + app, + get_max_request_size_mb=lambda: 0, + is_request_size_limit_enabled=lambda: True, + ) + ) + + response = client.post( + "/chat/completions", + content=b"x", + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 200 + assert response.json() == {"ok": True} + assert downstream_called is True + + +@pytest.mark.asyncio +async def test_request_size_limit_middleware_rejects_streamed_body_without_content_length(): + received_body_bytes = 0 + + async def app(scope, receive, send): + nonlocal received_body_bytes + while True: + message = await receive() + if message["type"] == "http.disconnect": + break + received_body_bytes += len(message.get("body", b"")) + if not message.get("more_body", False): + break + + response = JSONResponse({"ok": True}) + await response(scope, receive, send) + + middleware = RequestSizeLimitMiddleware( + app, + get_max_request_size_mb=lambda: 1, + is_request_size_limit_enabled=lambda: True, + ) + sent_messages: list[Message] = [] + receive_messages: list[Message] = [ + { + "type": "http.request", + "body": b"x" * (1024 * 1024), + "more_body": True, + }, + { + "type": "http.request", + "body": b"y", + "more_body": False, + }, + ] + + async def receive(): + return receive_messages.pop(0) + + async def send(message): + sent_messages.append(message) + + await middleware( + { + "type": "http", + "method": "POST", + "path": "/chat/completions", + "headers": [(b"content-type", b"application/json")], + }, + receive, + send, + ) + + expected_body = b'{"error":"Request size is too large. Max size is 1 MB"}' + assert sent_messages[0] == { + "type": "http.response.start", + "status": 413, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(expected_body)).encode("latin-1")), + ], + } + assert sent_messages[1] == { + "type": "http.response.body", + "body": expected_body, + "more_body": False, + } + assert received_body_bytes == 1024 * 1024 diff --git a/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py b/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py index b4aac113f5..0daa5b17ff 100644 --- a/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py +++ b/tests/proxy_unit_tests/test_unit_test_max_model_budget_limiter.py @@ -413,3 +413,71 @@ async def test_async_log_success_event_uses_end_user_model_budget_duration( f"{END_USER_SPEND_CACHE_KEY_PREFIX}:{end_user_id}:{model}:{budget_duration}" ) assert call_kwargs["response_cost"] == 0.05 + + +@pytest.mark.asyncio +async def test_async_log_success_event_pushes_redis_increments_when_redis_configured(): + """ + Virtual-key model max budget limiter does not run RouterBudgetLimiting.__init__, + so the periodic Redis flush task never starts. After logging spend we must call + _push_in_memory_increments_to_redis when Redis is wired so other workers see spend. + """ + dual_cache = DualCache() + dual_cache.redis_cache = object() # truthy placeholder; push only checks is not None + limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=dual_cache) + model = "gpt-4" + kwargs = { + "standard_logging_object": { + "response_cost": 0.01, + "model": model, + "metadata": {"user_api_key_hash": "vk-hash"}, + }, + "litellm_params": { + "metadata": { + "user_api_key_model_max_budget": { + model: {"budget_limit": 10.0, "time_period": "1d"}, + }, + }, + }, + } + with patch.object(limiter, "_increment_spend_for_key", new_callable=AsyncMock): + with patch.object( + limiter, + "_push_in_memory_increments_to_redis", + new_callable=AsyncMock, + ) as mock_push: + await limiter.async_log_success_event( + kwargs, response_obj=None, start_time=None, end_time=None + ) + mock_push.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_async_log_success_event_skips_redis_push_without_redis(budget_limiter): + """When dual_cache has no Redis backend, do not await _push_in_memory_increments_to_redis.""" + assert budget_limiter.dual_cache.redis_cache is None + model = "gpt-4" + kwargs = { + "standard_logging_object": { + "response_cost": 0.01, + "model": model, + "metadata": {"user_api_key_hash": "vk-hash"}, + }, + "litellm_params": { + "metadata": { + "user_api_key_model_max_budget": { + model: {"budget_limit": 10.0, "time_period": "1d"}, + }, + }, + }, + } + with patch.object(budget_limiter, "_increment_spend_for_key", new_callable=AsyncMock): + with patch.object( + budget_limiter, + "_push_in_memory_increments_to_redis", + new_callable=AsyncMock, + ) as mock_push: + await budget_limiter.async_log_success_event( + kwargs, response_obj=None, start_time=None, end_time=None + ) + mock_push.assert_not_awaited() diff --git a/tests/test_litellm/integrations/test_azure_sentinel.py b/tests/test_litellm/integrations/test_azure_sentinel.py index 031b85211f..30b246202f 100644 --- a/tests/test_litellm/integrations/test_azure_sentinel.py +++ b/tests/test_litellm/integrations/test_azure_sentinel.py @@ -2,13 +2,18 @@ Test Azure Sentinel logging integration """ -import datetime -from unittest.mock import AsyncMock, patch +import json +from unittest.mock import AsyncMock, MagicMock, patch import pytest from litellm.integrations.azure_sentinel.azure_sentinel import AzureSentinelLogger -from litellm.types.utils import StandardLoggingPayload +from litellm.types.utils import StandardAuditLogPayload, StandardLoggingPayload + + +def _close_periodic_flush_task(coro): + coro.close() + return None @pytest.mark.asyncio @@ -20,7 +25,7 @@ async def test_azure_sentinel_oauth_and_send_batch(): test_client_id = "test-client-id" test_client_secret = "test-client-secret" - with patch("asyncio.create_task"): + with patch("asyncio.create_task", side_effect=_close_periodic_flush_task): logger = AzureSentinelLogger( dcr_immutable_id=test_dcr_id, endpoint=test_endpoint, @@ -42,9 +47,6 @@ async def test_azure_sentinel_oauth_and_send_batch(): # Add to queue logger.log_queue.append(standard_payload) - # Mock OAuth token response - from unittest.mock import MagicMock - mock_token_response = MagicMock() mock_token_response.status_code = 200 mock_token_response.json = MagicMock( @@ -91,3 +93,173 @@ async def test_azure_sentinel_oauth_and_send_batch(): # Verify queue is cleared assert len(logger.log_queue) == 0 + + +@pytest.mark.asyncio +async def test_azure_sentinel_queues_audit_log_event(): + """Test that Azure Sentinel supports direct audit log callbacks""" + with patch("asyncio.create_task", side_effect=_close_periodic_flush_task): + logger = AzureSentinelLogger( + dcr_immutable_id="dcr-test123456789", + endpoint="https://test-dce.eastus-1.ingest.monitor.azure.com", + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + logger.batch_size = 2 + logger.async_send_audit_batch = AsyncMock() + + audit_log = StandardAuditLogPayload( + id="audit-123", + updated_at="2026-05-06T04:39:00+00:00", + changed_by="user-1", + changed_by_api_key="sk-test", + action="created", + table_name="LiteLLM_TeamTable", + object_id="team-1", + before_value=None, + updated_values='{"team_alias": "sentinel-demo"}', + ) + + await logger.async_log_audit_log_event(audit_log) + + assert logger.audit_log_queue == [audit_log] + logger.async_send_audit_batch.assert_not_called() + + await logger.async_log_audit_log_event(audit_log) + + assert logger.audit_log_queue == [audit_log, audit_log] + logger.async_send_audit_batch.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_azure_sentinel_sends_audit_log_payload_to_ingestion_api(): + """Test that queued audit logs are sent to Azure Monitor Logs Ingestion""" + with patch("asyncio.create_task", side_effect=_close_periodic_flush_task): + logger = AzureSentinelLogger( + dcr_immutable_id="dcr-test123456789", + endpoint="https://test-dce.eastus-1.ingest.monitor.azure.com", + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + audit_log = StandardAuditLogPayload( + id="audit-123", + updated_at="2026-05-06T04:39:00+00:00", + changed_by="user-1", + changed_by_api_key="sk-test", + action="created", + table_name="LiteLLM_TeamTable", + object_id="team-1", + before_value=None, + updated_values='{"team_alias": "sentinel-demo"}', + ) + await logger.async_log_audit_log_event(audit_log) + + mock_token_response = MagicMock() + mock_token_response.status_code = 200 + mock_token_response.json = MagicMock( + return_value={ + "access_token": "test-bearer-token", + "expires_in": 3600, + } + ) + mock_token_response.text = "Success" + + mock_api_response = MagicMock() + mock_api_response.status_code = 204 + mock_api_response.text = "Success" + + async def mock_post(*args, **kwargs): + if "oauth2/v2.0/token" in kwargs.get("url", ""): + return mock_token_response + return mock_api_response + + logger.async_httpx_client.post = AsyncMock(side_effect=mock_post) + + await logger.flush_queue() + + api_call_args = logger.async_httpx_client.post.call_args_list[-1] + body = json.loads(api_call_args.kwargs["data"].decode("utf-8")) + assert body == [audit_log] + assert "dcr-test123456789" in api_call_args.kwargs["url"] + assert "Custom-LiteLLM" in api_call_args.kwargs["url"] + assert len(logger.audit_log_queue) == 0 + + +@pytest.mark.asyncio +async def test_azure_sentinel_flushes_standard_and_audit_logs_separately(): + """Test mixed callback roles do not send schema-mismatched batches.""" + with patch("asyncio.create_task", side_effect=_close_periodic_flush_task): + logger = AzureSentinelLogger( + dcr_immutable_id="dcr-test123456789", + stream_name="Custom-LiteLLM-Standard", + audit_stream_name="Custom-LiteLLM-Audit", + endpoint="https://test-dce.eastus-1.ingest.monitor.azure.com", + tenant_id="test-tenant-id", + client_id="test-client-id", + client_secret="test-client-secret", + ) + + standard_payload = StandardLoggingPayload( + id="standard-123", + call_type="completion", + model="gpt-3.5-turbo", + status="success", + messages=[{"role": "user", "content": "Hello"}], + response={"choices": [{"message": {"content": "Hi"}}]}, + ) + audit_log = StandardAuditLogPayload( + id="audit-123", + updated_at="2026-05-06T04:39:00+00:00", + changed_by="user-1", + changed_by_api_key="sk-test", + action="created", + table_name="LiteLLM_TeamTable", + object_id="team-1", + before_value=None, + updated_values='{"team_alias": "sentinel-demo"}', + ) + + logger.log_queue.append(standard_payload) + await logger.async_log_audit_log_event(audit_log) + + mock_token_response = MagicMock() + mock_token_response.status_code = 200 + mock_token_response.json = MagicMock( + return_value={ + "access_token": "test-bearer-token", + "expires_in": 3600, + } + ) + mock_token_response.text = "Success" + + mock_api_response = MagicMock() + mock_api_response.status_code = 204 + mock_api_response.text = "Success" + + async def mock_post(*args, **kwargs): + if "oauth2/v2.0/token" in kwargs.get("url", ""): + return mock_token_response + return mock_api_response + + logger.async_httpx_client.post = AsyncMock(side_effect=mock_post) + + await logger.flush_queue() + + ingestion_calls = [ + call + for call in logger.async_httpx_client.post.call_args_list + if "dataCollectionRules" in call.kwargs["url"] + ] + assert len(ingestion_calls) == 2 + + standard_call, audit_call = ingestion_calls + assert "Custom-LiteLLM-Standard" in standard_call.kwargs["url"] + assert json.loads(standard_call.kwargs["data"].decode("utf-8")) == [ + standard_payload + ] + assert "Custom-LiteLLM-Audit" in audit_call.kwargs["url"] + assert json.loads(audit_call.kwargs["data"].decode("utf-8")) == [audit_log] diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index aeb6d826f1..898f42b45b 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -442,6 +442,109 @@ class TestOpenTelemetryDualHandlerIsolation(unittest.TestCase): ) +class TestOpenTelemetryCaptureMessageContent(unittest.TestCase): + """OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT and the + OpenTelemetryConfig.capture_message_content programmatic override + drive what the handler captures in spans vs events.""" + + @staticmethod + def _make(env=None, config_value=None, message_logging=True): + env_dict = ( + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": env} + if env is not None + else {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""} + ) + with patch.dict(os.environ, env_dict): + handler = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content=config_value + ) + ) + handler.message_logging = message_logging + return handler, handler._resolve_capture_mode() + + def test_no_explicit_setting_falls_back_to_message_logging_true(self): + _, mode = self._make() + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_no_explicit_setting_falls_back_to_message_logging_false(self): + _, mode = self._make(message_logging=False) + self.assertEqual(mode, "NO_CONTENT") + + def test_env_var_no_content(self): + _, mode = self._make(env="NO_CONTENT") + self.assertEqual(mode, "NO_CONTENT") + + def test_env_var_span_only(self): + _, mode = self._make(env="SPAN_ONLY") + self.assertEqual(mode, "SPAN_ONLY") + + def test_env_var_event_only(self): + _, mode = self._make(env="EVENT_ONLY") + self.assertEqual(mode, "EVENT_ONLY") + + def test_env_var_span_and_event(self): + _, mode = self._make(env="SPAN_AND_EVENT") + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_env_var_legacy_true_maps_to_event_only(self): + _, mode = self._make(env="true") + self.assertEqual(mode, "EVENT_ONLY") + + def test_env_var_legacy_false_maps_to_no_content(self): + for env in ("false", "0"): + with self.subTest(env=env): + _, mode = self._make(env=env) + self.assertEqual(mode, "NO_CONTENT") + + def test_env_var_unknown_value_falls_through_to_legacy(self): + _, mode = self._make(env="garbage", message_logging=True) + self.assertEqual(mode, "SPAN_AND_EVENT") + + def test_config_field_overrides_env(self): + _, mode = self._make(env="EVENT_ONLY", config_value="SPAN_ONLY") + self.assertEqual(mode, "SPAN_ONLY") + + def test_turn_off_message_logging_forces_no_content(self): + with patch("litellm.turn_off_message_logging", True): + _, mode = self._make(env="SPAN_AND_EVENT", message_logging=True) + self.assertEqual(mode, "NO_CONTENT") + + def test_capture_in_span_and_event_predicates(self): + cases = { + "NO_CONTENT": (False, False), + "SPAN_ONLY": (True, False), + "EVENT_ONLY": (False, True), + "SPAN_AND_EVENT": (True, True), + } + for mode, (in_span, in_event) in cases.items(): + handler, _ = self._make(env=mode) + self.assertEqual(handler._capture_in_span(), in_span, msg=mode) + self.assertEqual(handler._capture_in_event(), in_event, msg=mode) + + def test_two_handlers_can_have_different_modes(self): + # FIL's stated requirement: one handler strips content, the other keeps it. + with patch.dict( + os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""} + ): + stripped = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content="NO_CONTENT" + ) + ) + kept = OpenTelemetry( + config=OpenTelemetryConfig( + exporter="console", capture_message_content="SPAN_AND_EVENT" + ) + ) + self.assertEqual(stripped._resolve_capture_mode(), "NO_CONTENT") + self.assertEqual(kept._resolve_capture_mode(), "SPAN_AND_EVENT") + self.assertFalse(stripped._capture_in_span()) + self.assertFalse(stripped._capture_in_event()) + self.assertTrue(kept._capture_in_span()) + self.assertTrue(kept._capture_in_event()) + + class TestOpenTelemetry(unittest.TestCase): POLL_INTERVAL = 0.05 POLL_TIMEOUT = 2.0 @@ -1067,6 +1170,7 @@ class TestOpenTelemetry(unittest.TestCase): result = otel._get_span_name(kwargs) self.assertEqual(result, LITELLM_REQUEST_SPAN_NAME) + @patch.dict(os.environ, {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}) @patch("litellm.turn_off_message_logging", False) def test_maybe_log_raw_request_creates_span(self): """Test _maybe_log_raw_request creates span when logging enabled""" @@ -2194,6 +2298,19 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): See: https://github.com/BerriAI/litellm/issues/17794 """ + def setUp(self): + # Insulate from a shell-set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + # so these tests exercise the legacy default path (message_logging=True). + self._prev = os.environ.pop( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", None + ) + + def tearDown(self): + if self._prev is not None: + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + self._prev + ) + def test_input_messages_uses_parts_structure(self): """ Test that gen_ai.input.messages uses the OTEL 1.38 parts array structure. diff --git a/tests/test_litellm/integrations/test_prometheus_custom_metadata_label_counts.py b/tests/test_litellm/integrations/test_prometheus_custom_metadata_label_counts.py new file mode 100644 index 0000000000..99eb5abb7b --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_custom_metadata_label_counts.py @@ -0,0 +1,159 @@ +import logging +import sys + +import pytest +from prometheus_client import REGISTRY + +import litellm +from litellm.integrations.prometheus import PrometheusLogger + + +def _clear_prometheus_registry() -> None: + collectors = list(REGISTRY._collector_to_names.keys()) + for collector in collectors: + REGISTRY.unregister(collector) + + +def _create_prometheus_logger_with_custom_labels(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + litellm, + "custom_prometheus_metadata_labels", + ["metadata.department", "metadata.environment"], + ) + _clear_prometheus_registry() + return PrometheusLogger() + + +def _standard_logging_payload_with_requester_metadata() -> dict: + return { + "model_id": "model-123", + "model_group": "gpt-4o-mini", + "api_base": "https://api.openai.com", + "custom_llm_provider": "openai", + "metadata": { + "user_api_key_hash": "test-hash", + "user_api_key_alias": "test-alias", + "user_api_key_team_id": "test-team", + "user_api_key_team_alias": "test-team-alias", + "user_api_key_user_id": "test-user", + "user_api_key_user_email": "test@example.com", + "user_api_key_org_id": None, + "requester_metadata": { + "department": "engineering", + "environment": "production", + }, + "user_api_key_auth_metadata": None, + "spend_logs_metadata": None, + }, + "request_tags": [], + "completion_tokens": 0, + "total_tokens": 0, + "response_cost": 0, + } + + +def _metric_samples(metric_name: str): + return [ + sample + for metric in REGISTRY.collect() + for sample in metric.samples + if sample.name == metric_name + ] + + +@pytest.mark.asyncio +async def test_async_log_failure_event_accepts_custom_metadata_labels( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + prometheus_logger = _create_prometheus_logger_with_custom_labels(monkeypatch) + kwargs = { + "model": "gpt-4o-mini", + "litellm_params": { + "metadata": { + "user_api_key_end_user_id": "test-end-user", + } + }, + "standard_logging_object": _standard_logging_payload_with_requester_metadata(), + } + + with caplog.at_level(logging.ERROR): + await prometheus_logger.async_log_failure_event( + kwargs=kwargs, + response_obj=None, + start_time=None, + end_time=None, + ) + + assert "Incorrect label count" not in caplog.text + samples = _metric_samples("litellm_llm_api_failed_requests_metric_total") + assert any( + sample.labels.get("metadata_department") == "engineering" + and sample.labels.get("metadata_environment") == "production" + for sample in samples + ) + + +def test_virtual_key_rate_limit_metrics_accept_custom_metadata_labels( + monkeypatch: pytest.MonkeyPatch, +): + prometheus_logger = _create_prometheus_logger_with_custom_labels(monkeypatch) + metadata = { + "model_group": "gpt-4o-mini", + "litellm-key-remaining-requests-gpt-4o-mini": 3, + "litellm-key-remaining-tokens-gpt-4o-mini": 200, + } + kwargs = { + "litellm_params": { + "metadata": metadata, + }, + "standard_logging_object": _standard_logging_payload_with_requester_metadata(), + } + + prometheus_logger._set_virtual_key_rate_limit_metrics( + user_api_key="test-hash", + user_api_key_alias="test-alias", + kwargs=kwargs, + metadata=metadata, + model_id="model-123", + ) + + samples = _metric_samples("litellm_remaining_api_key_requests_for_model") + assert any( + sample.labels.get("metadata_department") == "engineering" + and sample.labels.get("metadata_environment") == "production" + and sample.value == 3 + for sample in samples + ) + + +def test_virtual_key_rate_limit_metrics_preserve_zero_remaining_values( + monkeypatch: pytest.MonkeyPatch, +): + prometheus_logger = _create_prometheus_logger_with_custom_labels(monkeypatch) + metadata = { + "model_group": "gpt-4o-mini", + "litellm-key-remaining-requests-gpt-4o-mini": 0, + "litellm-key-remaining-tokens-gpt-4o-mini": 0, + } + kwargs = { + "litellm_params": { + "metadata": metadata, + }, + "standard_logging_object": _standard_logging_payload_with_requester_metadata(), + } + + prometheus_logger._set_virtual_key_rate_limit_metrics( + user_api_key="test-hash", + user_api_key_alias="test-alias", + kwargs=kwargs, + metadata=metadata, + model_id="model-123", + ) + + request_samples = _metric_samples("litellm_remaining_api_key_requests_for_model") + token_samples = _metric_samples("litellm_remaining_api_key_tokens_for_model") + + assert any(sample.value == 0 for sample in request_samples) + assert any(sample.value == 0 for sample in token_samples) + assert not any(sample.value == sys.maxsize for sample in request_samples) + assert not any(sample.value == sys.maxsize for sample in token_samples) diff --git a/tests/test_litellm/integrations/test_prometheus_end_user_cardinality.py b/tests/test_litellm/integrations/test_prometheus_end_user_cardinality.py new file mode 100644 index 0000000000..868d86a6c2 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_end_user_cardinality.py @@ -0,0 +1,181 @@ +from time import monotonic + +import pytest +from prometheus_client import REGISTRY + +import litellm +from litellm.integrations.prometheus import PrometheusLogger +from litellm.integrations.prometheus_helpers import bounded_prometheus_series_tracker +from litellm.integrations.prometheus_helpers.bounded_prometheus_series_tracker import ( + BoundedPrometheusSeriesTracker, +) +from litellm.types.integrations.prometheus import UserAPIKeyLabelValues + + +@pytest.fixture(autouse=True) +def cleanup_prometheus_registry(): + collectors = list(REGISTRY._collector_to_names.keys()) + for collector in collectors: + try: + REGISTRY.unregister(collector) + except Exception: + pass + + old_enable_end_user = litellm.enable_end_user_cost_tracking_prometheus_only + old_metrics_config = litellm.prometheus_metrics_config + old_max_series = litellm.prometheus_end_user_metrics_max_series_per_metric + old_ttl_seconds = litellm.prometheus_end_user_metrics_ttl_seconds + old_cleanup_interval_seconds = ( + litellm.prometheus_end_user_metrics_cleanup_interval_seconds + ) + + yield + + litellm.enable_end_user_cost_tracking_prometheus_only = old_enable_end_user + litellm.prometheus_metrics_config = old_metrics_config + litellm.prometheus_end_user_metrics_max_series_per_metric = old_max_series + litellm.prometheus_end_user_metrics_ttl_seconds = old_ttl_seconds + litellm.prometheus_end_user_metrics_cleanup_interval_seconds = ( + old_cleanup_interval_seconds + ) + + collectors = list(REGISTRY._collector_to_names.keys()) + for collector in collectors: + try: + REGISTRY.unregister(collector) + except Exception: + pass + + +def test_prometheus_end_user_series_are_capped_per_metric(): + litellm.enable_end_user_cost_tracking_prometheus_only = True + litellm.prometheus_metrics_config = [ + { + "group": "end-user-spend", + "metrics": ["litellm_spend_metric"], + "include_labels": ["end_user"], + } + ] + litellm.prometheus_end_user_metrics_max_series_per_metric = 3 + litellm.prometheus_end_user_metrics_ttl_seconds = None + logger = PrometheusLogger() + + for index in range(6): + PrometheusLogger._inc_labeled_counter( + logger, + logger.litellm_spend_metric, + "litellm_spend_metric", + UserAPIKeyLabelValues(end_user=f"end-user-{index}"), + amount=0.01, + ) + + assert len(logger.litellm_spend_metric._metrics) == 3 + assert set(logger.litellm_spend_metric._metrics) == { + ("end-user-3",), + ("end-user-4",), + ("end-user-5",), + } + + +def test_bounded_prometheus_series_tracker_is_label_agnostic(): + class FakeMetric: + def __init__(self): + self.removed_label_values = [] + + def remove(self, *label_values): + self.removed_label_values.append(label_values) + + metric = FakeMetric() + tracker = BoundedPrometheusSeriesTracker() + + for index in range(4): + tracker.track_series( + metric=metric, + metric_name="generic_metric", + label_values=(f"route-{index}", "200"), + max_series=2, + ttl_seconds=None, + cleanup_interval_seconds=60.0, + ) + + assert metric.removed_label_values == [ + ("route-0", "200"), + ("route-1", "200"), + ] + + +def test_bounded_prometheus_series_tracker_treats_zero_max_as_unlimited(): + # A misconfigured ``max_series=0`` must not silently evict every emission. + class FakeMetric: + def __init__(self): + self.removed_label_values = [] + + def remove(self, *label_values): + self.removed_label_values.append(label_values) + + metric = FakeMetric() + tracker = BoundedPrometheusSeriesTracker() + + for index in range(3): + tracker.track_series( + metric=metric, + metric_name="generic_metric", + label_values=(f"end-user-{index}",), + max_series=0, + ttl_seconds=None, + cleanup_interval_seconds=60.0, + ) + + assert metric.removed_label_values == [] + + +def test_prometheus_end_user_series_expire_by_ttl(monkeypatch): + litellm.enable_end_user_cost_tracking_prometheus_only = True + litellm.prometheus_metrics_config = [ + { + "group": "end-user-spend", + "metrics": ["litellm_spend_metric"], + "include_labels": ["end_user"], + } + ] + litellm.prometheus_end_user_metrics_max_series_per_metric = None + litellm.prometheus_end_user_metrics_ttl_seconds = 10.0 + litellm.prometheus_end_user_metrics_cleanup_interval_seconds = 0.0 + logger = PrometheusLogger() + + current_time = [monotonic()] + monkeypatch.setattr( + bounded_prometheus_series_tracker.time, + "monotonic", + lambda: current_time[0], + ) + PrometheusLogger._inc_labeled_counter( + logger, + logger.litellm_spend_metric, + "litellm_spend_metric", + UserAPIKeyLabelValues(end_user="stale-end-user"), + amount=0.01, + ) + + current_time[0] += 11.0 + PrometheusLogger._inc_labeled_counter( + logger, + logger.litellm_spend_metric, + "litellm_spend_metric", + UserAPIKeyLabelValues(end_user="fresh-end-user"), + amount=0.01, + ) + + assert set(logger.litellm_spend_metric._metrics) == {("fresh-end-user",)} + + +def test_prometheus_end_user_not_tracked_by_default(): + litellm.enable_end_user_cost_tracking_prometheus_only = None + labels = PrometheusLogger().get_labels_for_metric("litellm_spend_metric") + assert "end_user" in labels + + label_values = UserAPIKeyLabelValues(end_user="not-exported") + from litellm.integrations.prometheus import prometheus_label_factory + + prometheus_labels = prometheus_label_factory(labels, label_values) + assert prometheus_labels["end_user"] is None diff --git a/tests/test_litellm/integrations/test_s3_v2.py b/tests/test_litellm/integrations/test_s3_v2.py index 771002db92..3f21de41c5 100644 --- a/tests/test_litellm/integrations/test_s3_v2.py +++ b/tests/test_litellm/integrations/test_s3_v2.py @@ -1123,3 +1123,74 @@ async def test_combined_prefix_reflects_in_s3_object_key(): result = logger.create_s3_batch_logging_element(datetime.utcnow(), payload) key = result.s3_object_key assert "myteam/apikey/" in key, f"Expected both prefixes in key: {key}" + + +# -------------------------------------------------------------- +# params_source / s3_callback_params_override (audit-log decoupling) +# -------------------------------------------------------------- +def test_s3_callback_params_override_uses_alternate_dict(): + """`s3_callback_params_override` makes the logger read its config from + the override dict instead of `litellm.s3_callback_params`.""" + import litellm + + original = litellm.s3_callback_params + litellm.s3_callback_params = {"s3_bucket_name": "normal-bucket"} + try: + logger = S3Logger( + s3_callback_params_override={ + "s3_bucket_name": "audit-bucket", + "s3_path": "audit-prefix", + "s3_region_name": "us-west-2", + } + ) + assert logger.s3_bucket_name == "audit-bucket" + assert logger.s3_path == "audit-prefix" + assert logger.s3_region_name == "us-west-2" + finally: + litellm.s3_callback_params = original + + +def test_s3_callback_params_override_does_not_mutate_inputs(monkeypatch): + """Resolving `os.environ/X` markers must not mutate the override dict + or `litellm.s3_callback_params`.""" + import litellm + + monkeypatch.setenv("MY_AUDIT_BUCKET", "resolved-bucket") + override = {"s3_bucket_name": "os.environ/MY_AUDIT_BUCKET"} + original_global = litellm.s3_callback_params + litellm.s3_callback_params = {"s3_bucket_name": "os.environ/MY_AUDIT_BUCKET"} + try: + logger = S3Logger(s3_callback_params_override=override) + assert logger.s3_bucket_name == "resolved-bucket" + assert override["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET" + assert ( + litellm.s3_callback_params["s3_bucket_name"] == "os.environ/MY_AUDIT_BUCKET" + ) + finally: + litellm.s3_callback_params = original_global + + +def test_s3_callback_params_override_none_falls_back_to_global(): + """No override → behaves exactly as today (reads `litellm.s3_callback_params`).""" + import litellm + + original = litellm.s3_callback_params + litellm.s3_callback_params = {"s3_bucket_name": "from-global"} + try: + logger = S3Logger() + assert logger.s3_bucket_name == "from-global" + finally: + litellm.s3_callback_params = original + + +def test_s3_callback_params_override_empty_dict_is_opt_in(): + """An empty override dict skips the global entirely (env/IAM-only config).""" + import litellm + + original = litellm.s3_callback_params + litellm.s3_callback_params = {"s3_bucket_name": "from-global"} + try: + logger = S3Logger(s3_callback_params_override={}) + assert logger.s3_bucket_name is None + finally: + litellm.s3_callback_params = original diff --git a/tests/test_litellm/interactions/test_openapi_compliance.py b/tests/test_litellm/interactions/test_openapi_compliance.py index cfcc426aa2..11d61d4c82 100644 --- a/tests/test_litellm/interactions/test_openapi_compliance.py +++ b/tests/test_litellm/interactions/test_openapi_compliance.py @@ -157,14 +157,15 @@ class TestResponseCompliance: # Check CreateModelInteractionParams which includes output fields schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"] - # Output fields (readOnly) + # Output fields (readOnly). Google renamed `outputs` → `steps` in the + # upstream spec; keep this list aligned with the live schema. output_fields = [ "id", "status", "created", "updated", "role", - "outputs", + "steps", "usage", ] diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 22d2610eec..99dbfd19f3 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -367,3 +367,128 @@ def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks(): # Messages pass through unchanged when there is no `file` sub-dict to remap. assert updated == messages + + +# Reusable fixture (decodes to: litellm_proxy:application/pdf;unified_id,...; +# target_model_names,gpt-4o;llm_output_file_id,file-ECBPW7ML9g7XHdwGgUPZaM; +# llm_output_file_model_id,...) +UNIFIED_FILE_ID_B64 = ( + "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9wZGY7dW5pZmllZF9pZCw2YzBiNTg5MC04OTE0" + "LTQ4ZTAtYjhmNC0wYWU1ZWQzYzE0YTU7dGFyZ2V0X21vZGVsX25hbWVzLGdwdC00bztsbG1f" + "b3V0cHV0X2ZpbGVfaWQsZmlsZS1FQ0JQVzdNTDlnN1hIZHdHZ1VQWmFNO2xsbV9vdXRwdXRf" + "ZmlsZV9tb2RlbF9pZCxlMjY0NTNmOWU3NmU3OTkzNjgwZDAwNjhkOThjMWY0Y2MyMDViYmFk" + "MDk2N2EzM2M2NjQ4OTM1NjhjYTc0M2My" +) + + +def test_update_messages_with_model_file_ids_decodes_unified_id_when_mapping_empty(): + """When the mapping is empty (e.g. multi-replica cache miss), the function + must decode the base64-encoded unified file id and substitute the embedded + llm_output_file_id — mirroring the Responses-API sibling.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this recording?"}, + { + "type": "file", + "file": { + "file_id": UNIFIED_FILE_ID_B64, + "format": "audio/wav", + }, + }, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "any-model-id", {}) + + assert updated[0]["content"][1]["file"]["file_id"] == "file-ECBPW7ML9g7XHdwGgUPZaM" + # Customer-supplied format is preserved (this is the field whose absence + # the misleading error message used to complain about). + assert updated[0]["content"][1]["file"]["format"] == "audio/wav" + + +def test_update_messages_with_model_file_ids_mapping_takes_precedence_over_decode(): + """When both mapping and decode would resolve, the mapping must win + (preserves per-deployment routing precision).""" + mapping = {UNIFIED_FILE_ID_B64: {"model-A": "mapped-provider-file-id"}} + messages = [ + { + "role": "user", + "content": [ + { + "type": "file", + "file": { + "file_id": UNIFIED_FILE_ID_B64, + "format": "application/pdf", + }, + }, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-A", mapping) + + assert updated[0]["content"][0]["file"]["file_id"] == "mapped-provider-file-id" + + +def test_update_messages_with_model_file_ids_non_unified_passes_through(): + """A raw provider id (e.g. gs:// URI or a random string) must be left + untouched when the mapping doesn't resolve it. The decode fallback must + not corrupt non-unified ids.""" + raw_id = "gs://my-bucket/uploads/abc-123.wav" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_id": raw_id, "format": "audio/wav"}}, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-A", {}) + + assert updated[0]["content"][0]["file"]["file_id"] == raw_id + + +def test_update_messages_with_model_file_ids_mapping_miss_falls_back_to_decode(): + """A mapping that exists but doesn't contain this file_id should still + trigger the decode fallback — covers the case where the hook resolved + *some* ids but not this one.""" + other_id = "some-other-file-id" + mapping = {other_id: {"model-A": "other-provider-id"}} + messages = [ + { + "role": "user", + "content": [ + { + "type": "file", + "file": {"file_id": UNIFIED_FILE_ID_B64, "format": "audio/wav"}, + }, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-A", mapping) + + assert updated[0]["content"][0]["file"]["file_id"] == "file-ECBPW7ML9g7XHdwGgUPZaM" + + +def test_update_messages_with_model_file_ids_tolerates_non_dict_content_items(): + """Content list items aren't always dicts. text_completion forwards + token-ids (list of ints, or list of list of ints for batch) through + this path. The function must skip non-dict items instead of indexing + into them.""" + messages_token_ids = [{"role": "user", "content": [15496, 995]}] + messages_token_ids_batch = [{"role": "user", "content": [[15496, 995], [9906, 0]]}] + + # Both should pass through unchanged without raising. + assert ( + update_messages_with_model_file_ids(messages_token_ids, "model-A", {}) + == messages_token_ids + ) + assert ( + update_messages_with_model_file_ids(messages_token_ids_batch, "model-A", {}) + == messages_token_ids_batch + ) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index bf0461d89f..2fdd639e74 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -1,7 +1,11 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from unittest.mock import AsyncMock, MagicMock import pytest +import litellm from litellm.constants import RESPONSE_FORMAT_TOOL_NAME from litellm.llms.anthropic.chat.handler import ModelResponseIterator, make_call from litellm.types.llms.openai import ( @@ -343,6 +347,289 @@ def test_text_only_streaming_has_index_zero(): ), f"Expected index=0, got {parsed.choices[0].index}" +def test_streaming_thinking_deltas_count_reasoning_tokens_in_usage(): + """Anthropic streaming usage should account for emitted thinking deltas.""" + chunks = [ + { + "type": "message_start", + "message": { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [], + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "First I need to count the favorable outcomes. ", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "Then I compare that count with all possible outcomes.", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig_123"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "The probability is 3/8."}, + }, + {"type": "content_block_stop", "index": 1}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 50}, + }, + ] + + iterator = ModelResponseIterator(None, sync_stream=True) + final_usage = None + reasoning_deltas = [] + + for chunk in chunks: + parsed = iterator.chunk_parser(chunk) + reasoning_content = getattr(parsed.choices[0].delta, "reasoning_content", None) + if reasoning_content: + reasoning_deltas.append(reasoning_content) + if parsed.usage is not None: + final_usage = parsed.usage + + assert reasoning_deltas == [ + "First I need to count the favorable outcomes. ", + "Then I compare that count with all possible outcomes.", + ] + assert final_usage is not None + completion_tokens_details = final_usage.completion_tokens_details + assert completion_tokens_details is not None + assert completion_tokens_details.reasoning_tokens > 0 + assert completion_tokens_details.text_tokens == ( + final_usage.completion_tokens - completion_tokens_details.reasoning_tokens + ) + + +def test_anthropic_completion_streaming_usage_matches_non_streaming_with_thinking(): + """The completion API should preserve Anthropic thinking usage in streaming mode.""" + thinking_parts = [ + "First I need to count the favorable outcomes. ", + "Then I compare that count with all possible outcomes.", + ] + thinking_text = "".join(thinking_parts) + answer_text = "The probability is 3/8." + requests_seen = [] + + class MockAnthropicHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format, *args): # type: ignore[no-untyped-def] + return + + def do_POST(self): # type: ignore[no-untyped-def] + content_length = int(self.headers.get("content-length", "0")) + payload = json.loads(self.rfile.read(content_length).decode("utf-8")) + requests_seen.append( + { + "path": self.path, + "model": payload.get("model"), + "stream": payload.get("stream", False), + "thinking": payload.get("thinking"), + } + ) + + if payload.get("stream"): + events = [ + { + "type": "message_start", + "message": { + "id": "msg_mock", + "type": "message", + "role": "assistant", + "model": payload.get("model"), + "content": [], + "usage": {"input_tokens": 10, "output_tokens": 1}, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": thinking_parts[0], + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": thinking_parts[1], + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "signature_delta", + "signature": "sig_mock", + }, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": answer_text}, + }, + {"type": "content_block_stop", "index": 1}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 50}, + }, + {"type": "message_stop"}, + ] + self._write_response( + content_type="text/event-stream", + body="".join( + f"data: {json.dumps(event)}\n\n" for event in events + ).encode("utf-8"), + ) + return + + self._write_response( + content_type="application/json", + body=json.dumps( + { + "id": "msg_mock", + "type": "message", + "role": "assistant", + "model": payload.get("model"), + "content": [ + { + "type": "thinking", + "thinking": thinking_text, + "signature": "sig_mock", + }, + {"type": "text", "text": answer_text}, + ], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 50}, + } + ).encode("utf-8"), + ) + + def _write_response(self, content_type: str, body: bytes) -> None: + self.send_response(200) + self.send_header("content-type", content_type) + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), MockAnthropicHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + request_kwargs = { + "model": "anthropic/claude-sonnet-4-6", + "api_base": f"http://127.0.0.1:{server.server_port}", + "api_key": "test", + "messages": [ + { + "role": "user", + "content": "Solve a probability problem and show thinking.", + } + ], + "thinking": {"type": "adaptive"}, + "max_tokens": 128, + } + + non_stream_response = litellm.completion(**request_kwargs, stream=False) + non_stream_details = non_stream_response.usage.completion_tokens_details + assert non_stream_details is not None + assert non_stream_details.reasoning_tokens > 0 + + reasoning_chunks = [] + content_chunks = [] + stream_usage = None + for chunk in litellm.completion( + **request_kwargs, + stream=True, + stream_options={"include_usage": True}, + ): + chunk_dict = chunk.model_dump(exclude_none=True) + choices = chunk_dict.get("choices") or [] + if choices: + delta = choices[0].get("delta") or {} + if delta.get("reasoning_content"): + reasoning_chunks.append(delta["reasoning_content"]) + if delta.get("content"): + content_chunks.append(delta["content"]) + if chunk_dict.get("usage"): + stream_usage = chunk_dict["usage"] + + assert reasoning_chunks == thinking_parts + assert content_chunks == [answer_text] + assert stream_usage is not None + stream_completion_details = stream_usage["completion_tokens_details"] + assert ( + stream_completion_details["reasoning_tokens"] + == non_stream_details.reasoning_tokens + ) + assert stream_completion_details["text_tokens"] == ( + stream_usage["completion_tokens"] + - stream_completion_details["reasoning_tokens"] + ) + assert requests_seen == [ + { + "path": "/v1/messages", + "model": "claude-sonnet-4-6", + "stream": False, + "thinking": {"type": "adaptive"}, + }, + { + "path": "/v1/messages", + "model": "claude-sonnet-4-6", + "stream": True, + "thinking": {"type": "adaptive"}, + }, + ] + finally: + server.shutdown() + + def test_text_and_tool_streaming_has_index_zero(): """Test that mixed text and tool streaming responses have choice index=0""" chunks = [ diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 6f67d5417d..e38698c910 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -97,6 +97,34 @@ def test_calculate_usage(): assert usage._cache_read_input_tokens == 0 +def test_calculate_usage_clamps_text_tokens_when_reasoning_estimate_exceeds_output(): + config = AnthropicConfig() + + usage = config.calculate_usage( + usage_object={"input_tokens": 10, "output_tokens": 1}, + reasoning_content="This reasoning text intentionally tokenizes above one output token.", + ) + + assert usage.completion_tokens == 1 + assert usage.completion_tokens_details is not None + assert usage.completion_tokens_details.reasoning_tokens == usage.completion_tokens + assert usage.completion_tokens_details.text_tokens == 0 + + +def test_calculate_usage_handles_mocked_output_tokens_with_reasoning_content(): + config = AnthropicConfig() + + usage = config.calculate_usage( + usage_object={"input_tokens": 10, "output_tokens": MagicMock()}, + reasoning_content="mocked response reasoning", + ) + + assert usage.completion_tokens == 0 + assert usage.completion_tokens_details is not None + assert usage.completion_tokens_details.reasoning_tokens == 0 + assert usage.completion_tokens_details.text_tokens == 0 + + @pytest.mark.parametrize( "usage_object,expected_usage", [ diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index c356f866b0..8fa9290d3d 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -13,6 +13,110 @@ sys.path.insert( from litellm.llms.bedrock.common_utils import BedrockModelInfo +# --------------------------------------------------------------------------- # +# BEDROCK_RESPONSE_STREAM_SHAPE eager-load tests # +# --------------------------------------------------------------------------- # + + +def test_bedrock_response_stream_shape_loaded_at_import(): + """ + BEDROCK_RESPONSE_STREAM_SHAPE is resolved at module import time. + In a standard environment with botocore installed it must be non-None. + """ + from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE + + assert BEDROCK_RESPONSE_STREAM_SHAPE is not None + + +def test_bedrock_response_stream_shape_load_failure_returns_none(): + """ + If botocore's Loader raises (e.g. missing data files), _load_bedrock_response_stream_shape + should return None rather than propagating the exception, so the module + still imports cleanly. + """ + from unittest.mock import patch + + import litellm.llms.bedrock.common_utils as mod + + with patch( + "botocore.loaders.Loader.load_service_model", + side_effect=Exception("no data"), + ): + shape = mod._load_bedrock_response_stream_shape() + assert shape is None + + +def test_bedrock_response_stream_shape_is_structure_shape(): + """ + The loaded shape should be the botocore StructureShape for ResponseStream, + not a plain dict or any other type. + """ + from botocore.model import StructureShape + + from litellm.llms.bedrock.common_utils import BEDROCK_RESPONSE_STREAM_SHAPE + + assert BEDROCK_RESPONSE_STREAM_SHAPE is not None, ( + "BEDROCK_RESPONSE_STREAM_SHAPE is None — botocore may not be installed" + ) + shape: StructureShape = BEDROCK_RESPONSE_STREAM_SHAPE # remove Optional + assert isinstance(shape, StructureShape) + assert shape.name == "ResponseStream" + + +def test_bedrock_response_stream_shape_same_object_across_imports(): + """ + Both bedrock modules that use the shape must reference the identical object — + confirming the constant is not re-loaded per import. + """ + from litellm.llms.bedrock.chat.invoke_handler import ( + BEDROCK_RESPONSE_STREAM_SHAPE as invoke_shape, + ) + from litellm.llms.bedrock.common_utils import ( + BEDROCK_RESPONSE_STREAM_SHAPE as common_shape, + ) + + assert common_shape is invoke_shape + + +def test_bedrock_event_stream_decoder_base_uses_module_shape(): + """ + BedrockEventStreamDecoderBase instances no longer carry their own + per-instance cache — _parse_message_from_event uses the module constant + directly, so there is no instance-level _response_stream_shape_cache attr. + """ + from litellm.llms.bedrock.common_utils import BedrockEventStreamDecoderBase + + decoder_a = BedrockEventStreamDecoderBase() + decoder_b = BedrockEventStreamDecoderBase() + + assert "_response_stream_shape_cache" not in decoder_a.__dict__ + assert "_response_stream_shape_cache" not in decoder_b.__dict__ + + +def test_bedrock_parse_message_from_event_raises_on_none_shape(): + """ + When BEDROCK_RESPONSE_STREAM_SHAPE is None (botocore unavailable), + _parse_message_from_event must raise BedrockError before touching the + botocore parser — not an opaque AttributeError from inside botocore. + """ + from unittest.mock import MagicMock, patch + + import litellm.llms.bedrock.common_utils as mod + from litellm.llms.bedrock.common_utils import BedrockError, BedrockEventStreamDecoderBase + + decoder = BedrockEventStreamDecoderBase() + mock_event = MagicMock() + + with patch.object(mod, "BEDROCK_RESPONSE_STREAM_SHAPE", None): + with pytest.raises(BedrockError) as exc_info: + decoder._parse_message_from_event(mock_event) + + assert exc_info.value.status_code == 500 + assert "botocore" in str(exc_info.value.message).lower() + # The botocore parser must never have been called + mock_event.to_response_dict.assert_not_called() + + def test_deepseek_cris(): """ Test that DeepSeek models with cross-region inference prefix use converse route diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index dd52304a70..26f50e8e49 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -1,8 +1,10 @@ +import asyncio import io import os import pathlib import ssl import sys +import threading from unittest.mock import MagicMock, patch import certifi @@ -18,11 +20,111 @@ from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, + MaskedHTTPStatusError, _get_httpx_client, get_ssl_configuration, ) +@pytest.mark.asyncio +async def test_async_post_streaming_status_error_should_not_wait_forever_for_body( + monkeypatch, +): + """ + Vertex Anthropic streamRawPredict can return a pre-stream 4xx where the + streamed error body never terminates. The handler must still surface the + status promptly instead of blocking the downstream client. + """ + + class HangingErrorStream(httpx.AsyncByteStream): + async def __aiter__(self): + await asyncio.Event().wait() + if False: + yield b"" + + async def mock_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + request=request, + headers={"content-type": "application/json"}, + stream=HangingErrorStream(), + ) + + monkeypatch.setattr( + "litellm.llms.custom_httpx.http_handler._STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS", + 0.01, + ) + + litellm_handler = AsyncHTTPHandler() + await litellm_handler.client.aclose() + litellm_handler.client = httpx.AsyncClient( + transport=httpx.MockTransport(mock_handler) + ) + try: + with pytest.raises(MaskedHTTPStatusError) as exc_info: + await asyncio.wait_for( + litellm_handler.post( + "https://vertex.example/streamRawPredict", + stream=True, + ), + timeout=0.2, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.response.status_code == 400 + finally: + await litellm_handler.close() + + +def test_sync_post_streaming_status_error_should_not_wait_forever_for_body( + monkeypatch, +): + """ + Keep the sync streaming error path aligned with the async path so a + non-terminating streamed error body cannot block a worker thread forever. + """ + + class HangingSyncErrorStream(httpx.SyncByteStream): + def __init__(self): + self.closed_event = threading.Event() + + def __iter__(self): + self.closed_event.wait() + if False: + yield b"" + + def close(self): + self.closed_event.set() + + def mock_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + request=request, + headers={"content-type": "application/json"}, + stream=HangingSyncErrorStream(), + ) + + monkeypatch.setattr( + "litellm.llms.custom_httpx.http_handler._STREAMING_ERROR_BODY_READ_TIMEOUT_SECONDS", + 0.01, + ) + + litellm_handler = HTTPHandler() + litellm_handler.client.close() + litellm_handler.client = httpx.Client(transport=httpx.MockTransport(mock_handler)) + try: + with pytest.raises(MaskedHTTPStatusError) as exc_info: + litellm_handler.post( + "https://vertex.example/streamRawPredict", + stream=True, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.response.status_code == 400 + finally: + litellm_handler.close() + + @pytest.mark.asyncio async def test_ssl_security_level(monkeypatch): # Ensure aiohttp transport is enabled for this test diff --git a/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py b/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py index 70a8d86cb1..9d7706557b 100644 --- a/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py +++ b/tests/test_litellm/llms/sagemaker/test_sagemaker_common_utils.py @@ -11,6 +11,102 @@ from litellm.llms.sagemaker.common_utils import AWSEventStreamDecoder from litellm.llms.sagemaker.completion.transformation import SagemakerConfig +# --------------------------------------------------------------------------- # +# SAGEMAKER_RESPONSE_STREAM_SHAPE eager-load tests # +# --------------------------------------------------------------------------- # + + +def test_sagemaker_response_stream_shape_loaded_at_import(): + """ + SAGEMAKER_RESPONSE_STREAM_SHAPE is resolved at module import time. + In a standard environment with botocore installed it must be non-None. + """ + from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + + assert SAGEMAKER_RESPONSE_STREAM_SHAPE is not None + + +def test_sagemaker_response_stream_shape_load_failure_returns_none(): + """ + If botocore's Loader raises (e.g. missing data files), _load_sagemaker_response_stream_shape + should return None rather than propagating the exception, so the module + still imports cleanly. + """ + from unittest.mock import patch + + import litellm.llms.sagemaker.common_utils as mod + + with patch( + "botocore.loaders.Loader.load_service_model", + side_effect=Exception("no data"), + ): + shape = mod._load_sagemaker_response_stream_shape() + assert shape is None + + +def test_sagemaker_response_stream_shape_is_structure_shape(): + """ + The loaded shape should be the botocore StructureShape for + InvokeEndpointWithResponseStreamOutput, not a plain dict or any other type. + """ + from botocore.model import StructureShape + + from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + + assert SAGEMAKER_RESPONSE_STREAM_SHAPE is not None, ( + "SAGEMAKER_RESPONSE_STREAM_SHAPE is None — botocore may not be installed" + ) + shape: StructureShape = SAGEMAKER_RESPONSE_STREAM_SHAPE # remove Optional + assert isinstance(shape, StructureShape) + assert shape.name == "InvokeEndpointWithResponseStreamOutput" + + +def test_sagemaker_response_stream_shape_not_reloaded_on_new_decoder(): + """ + Creating multiple AWSEventStreamDecoder instances must not trigger + additional botocore Loader calls — the shape is resolved once at import + time and reused. + """ + from litellm.llms.sagemaker.common_utils import SAGEMAKER_RESPONSE_STREAM_SHAPE + + decoder_a = AWSEventStreamDecoder(model="test-model-a") + decoder_b = AWSEventStreamDecoder(model="test-model-b") + + # Both decoders should use the same pre-loaded shape object (identity check) + assert "_response_stream_shape_cache" not in decoder_a.__dict__ + assert "_response_stream_shape_cache" not in decoder_b.__dict__ + # The module constant is still the same object + from litellm.llms.sagemaker.common_utils import ( + SAGEMAKER_RESPONSE_STREAM_SHAPE as shape_after, + ) + + assert SAGEMAKER_RESPONSE_STREAM_SHAPE is shape_after + + +def test_sagemaker_parse_message_from_event_raises_on_none_shape(): + """ + When SAGEMAKER_RESPONSE_STREAM_SHAPE is None (botocore unavailable), + _parse_message_from_event must raise ValueError before touching the + botocore parser — not an opaque AttributeError from inside botocore. + """ + from unittest.mock import MagicMock, patch + + import litellm.llms.sagemaker.common_utils as mod + from litellm.llms.sagemaker.common_utils import SagemakerError + + decoder = AWSEventStreamDecoder(model="test-model") + mock_event = MagicMock() + + with patch.object(mod, "SAGEMAKER_RESPONSE_STREAM_SHAPE", None): + with pytest.raises(SagemakerError) as exc_info: + decoder._parse_message_from_event(mock_event) + + assert exc_info.value.status_code == 500 + assert "botocore" in str(exc_info.value.message).lower() + # The botocore parser must never have been called + mock_event.to_response_dict.assert_not_called() + + @pytest.mark.asyncio async def test_aiter_bytes_unicode_decode_error(): """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_token_exchange.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_token_exchange.py new file mode 100644 index 0000000000..9ff4e01da5 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_token_exchange.py @@ -0,0 +1,511 @@ +""" +Tests for OAuth 2.0 Token Exchange (RFC 8693) handler for MCP servers. + +Covers: exchange flow, caching, error handling, resolve_mcp_auth integration, +bearer token extraction, and config loading. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from litellm.proxy._experimental.mcp_server.auth.token_exchange import ( + TOKEN_EXCHANGE_GRANT_TYPE, + TokenExchangeHandler, +) +from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, +) +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + resolve_mcp_auth, +) +from litellm.proxy._types import LiteLLM_MCPServerTable, MCPTransport +from litellm.types.mcp import MCPAuth +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +def _obo_server(**overrides) -> MCPServer: + defaults = dict( + server_id="srv-obo-1", + name="test-obo", + url="https://mcp.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_token_exchange, + client_id="litellm-client-id", + client_secret="litellm-client-secret", + token_exchange_endpoint="https://idp.example.com/oauth2/token", + audience="api://mcp-server", + scopes=["mcp.tools.read", "mcp.tools.execute"], + ) + defaults.update(overrides) + return MCPServer(**defaults) + + +def _exchange_response(token="exchanged-tok-abc", expires_in=3600): + resp = MagicMock() + resp.json.return_value = { + "access_token": token, + "token_type": "Bearer", + "expires_in": expires_in, + } + resp.raise_for_status = MagicMock() + resp.text = "" + return resp + + +# ── Exchange Flow ── + + +@pytest.mark.asyncio +async def test_exchange_token_success(): + """Token exchange sends correct RFC 8693 parameters and returns access_token.""" + handler = TokenExchangeHandler() + server = _obo_server() + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response("scoped-token-1") + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ): + result = await handler.exchange_token("user-jwt-xyz", server) + + assert result == "scoped-token-1" + mock_client.post.assert_called_once() + + _, kwargs = mock_client.post.call_args + data = kwargs["data"] + assert data["grant_type"] == TOKEN_EXCHANGE_GRANT_TYPE + assert data["subject_token"] == "user-jwt-xyz" + assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:access_token" + assert data["audience"] == "api://mcp-server" + assert data["scope"] == "mcp.tools.read mcp.tools.execute" + assert data["client_id"] == "litellm-client-id" + assert data["client_secret"] == "litellm-client-secret" + + +@pytest.mark.asyncio +async def test_exchange_token_no_audience(): + """When audience is None, it is omitted from the request.""" + handler = TokenExchangeHandler() + server = _obo_server(audience=None) + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response() + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ): + await handler.exchange_token("user-jwt", server) + + _, kwargs = mock_client.post.call_args + assert "audience" not in kwargs["data"] + + +@pytest.mark.asyncio +async def test_exchange_token_no_scopes(): + """When scopes is None, scope param is omitted from the request.""" + handler = TokenExchangeHandler() + server = _obo_server(scopes=None) + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response() + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ): + await handler.exchange_token("user-jwt", server) + + _, kwargs = mock_client.post.call_args + assert "scope" not in kwargs["data"] + + +# ── Caching ── + + +@pytest.mark.asyncio +async def test_exchange_token_cached(): + """Second call with same user token uses cache — only 1 HTTP POST.""" + handler = TokenExchangeHandler() + server = _obo_server() + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response("cached-exchange-tok") + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ): + t1 = await handler.exchange_token("same-jwt", server) + t2 = await handler.exchange_token("same-jwt", server) + + assert t1 == t2 == "cached-exchange-tok" + assert mock_client.post.call_count == 1 + + +@pytest.mark.asyncio +async def test_different_user_tokens_not_shared(): + """Different user JWTs get different exchanged tokens.""" + handler = TokenExchangeHandler() + server = _obo_server() + call_count = 0 + + async def mock_post(url, data=None): + nonlocal call_count + call_count += 1 + resp = MagicMock() + resp.json.return_value = { + "access_token": f"exchanged-{call_count}", + "expires_in": 3600, + } + resp.raise_for_status = MagicMock() + return resp + + mock_client = AsyncMock() + mock_client.post = mock_post + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ): + t1 = await handler.exchange_token("user-a-jwt", server) + t2 = await handler.exchange_token("user-b-jwt", server) + + assert t1 == "exchanged-1" + assert t2 == "exchanged-2" + assert call_count == 2 + + +# ── Error Handling ── + + +@pytest.mark.asyncio +async def test_exchange_token_http_error(): + """HTTP errors from the IDP are wrapped in a ValueError.""" + handler = TokenExchangeHandler() + server = _obo_server() + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "invalid_grant" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Bad Request", + request=MagicMock(), + response=mock_response, + ) + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ), + pytest.raises(ValueError, match="failed with status 400"), + ): + await handler.exchange_token("bad-jwt", server) + + +@pytest.mark.asyncio +async def test_exchange_token_http_error_does_not_log_response_body(): + """Raw IDP error bodies are not logged because they can contain credentials.""" + handler = TokenExchangeHandler() + server = _obo_server() + raw_response_body = "client_secret=do-not-log" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = raw_response_body + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Unauthorized", + request=MagicMock(), + response=mock_response, + ) + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.verbose_logger.debug" + ) as mock_debug, + pytest.raises(ValueError, match="failed with status 401"), + ): + await handler.exchange_token("bad-jwt", server) + + logged_values = " ".join( + str(value) + for call in mock_debug.call_args_list + for value in [*call.args, *call.kwargs.values()] + ) + assert raw_response_body not in logged_values + + +@pytest.mark.asyncio +async def test_exchange_token_missing_access_token(): + """Response without access_token raises ValueError.""" + handler = TokenExchangeHandler() + server = _obo_server() + resp = MagicMock() + resp.json.return_value = {"token_type": "Bearer"} + resp.raise_for_status = MagicMock() + mock_client = AsyncMock() + mock_client.post.return_value = resp + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.get_async_httpx_client", + return_value=mock_client, + ), + pytest.raises(ValueError, match="missing 'access_token'"), + ): + await handler.exchange_token("jwt", server) + + +@pytest.mark.asyncio +async def test_exchange_token_missing_endpoint(): + """Missing token_exchange_endpoint and token_url raises ValueError.""" + handler = TokenExchangeHandler() + server = _obo_server(token_exchange_endpoint=None, token_url=None) + + with pytest.raises(ValueError, match="no token_exchange_endpoint or token_url"): + await handler.exchange_token("jwt", server) + + +@pytest.mark.asyncio +async def test_exchange_token_missing_credentials(): + """Missing client_id or client_secret raises ValueError.""" + handler = TokenExchangeHandler() + server = _obo_server(client_id=None, client_secret=None) + # has_token_exchange_config will be False, so we call _do_exchange directly + with pytest.raises(ValueError, match="missing client_id or client_secret"): + await handler._do_exchange("jwt", server) + + +# ── resolve_mcp_auth Integration ── + + +@pytest.mark.asyncio +async def test_resolve_mcp_auth_with_token_exchange(): + """resolve_mcp_auth delegates to token exchange when server has OBO config and subject_token provided.""" + server = _obo_server() + mock_handler = AsyncMock() + mock_handler.exchange_token.return_value = "obo-scoped-token" + + with patch( + "litellm.proxy._experimental.mcp_server.auth.token_exchange.mcp_token_exchange_handler", + mock_handler, + ): + result = await resolve_mcp_auth(server, subject_token="user-jwt") + + assert result == "obo-scoped-token" + mock_handler.exchange_token.assert_called_once_with("user-jwt", server) + + +@pytest.mark.asyncio +async def test_resolve_mcp_auth_obo_without_subject_token_falls_through(): + """Without a subject_token, resolve_mcp_auth falls through to client_credentials.""" + server = _obo_server( + token_url="https://auth.example.com/token", + ) + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response("cc-token") + + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client", + return_value=mock_client, + ): + result = await resolve_mcp_auth(server, subject_token=None) + + # Falls through to client_credentials since subject_token is None + # The server has client_id/client_secret/token_url so has_client_credentials is True + assert result == "cc-token" + + +@pytest.mark.asyncio +async def test_resolve_mcp_auth_obo_without_subject_token_uses_cached_client_credentials(): + """The M2M fallback for OBO servers reuses the client_credentials cache.""" + server = _obo_server( + server_id="srv-obo-m2m-cache", + token_url="https://auth.example.com/token", + ) + mock_client = AsyncMock() + mock_client.post.return_value = _exchange_response("cached-cc-token") + + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client", + return_value=mock_client, + ): + first = await resolve_mcp_auth(server, subject_token=None) + second = await resolve_mcp_auth(server, subject_token=None) + + assert first == second == "cached-cc-token" + mock_client.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_resolve_mcp_auth_header_beats_obo(): + """An explicit mcp_auth_header takes priority over OBO token exchange.""" + server = _obo_server() + result = await resolve_mcp_auth( + server, mcp_auth_header="Bearer override", subject_token="user-jwt" + ) + assert result == "Bearer override" + + +# ── Bearer Token Extraction ── + + +def test_extract_bearer_token_from_oauth2_headers(): + """Extracts token from oauth2_headers Authorization header.""" + result = MCPServerManager._extract_bearer_token( + oauth2_headers={"Authorization": "Bearer my-jwt-token"}, + raw_headers=None, + ) + assert result == "my-jwt-token" + + +def test_extract_bearer_token_from_raw_headers(): + """Falls back to raw_headers when oauth2_headers missing.""" + result = MCPServerManager._extract_bearer_token( + oauth2_headers=None, + raw_headers={"authorization": "Bearer raw-jwt"}, + ) + assert result == "raw-jwt" + + +def test_extract_bearer_token_no_bearer_prefix(): + """Returns token as-is when no Bearer prefix.""" + result = MCPServerManager._extract_bearer_token( + oauth2_headers={"Authorization": "some-opaque-token"}, + raw_headers=None, + ) + assert result == "some-opaque-token" + + +def test_extract_bearer_token_none(): + """Returns None when no auth headers present.""" + result = MCPServerManager._extract_bearer_token( + oauth2_headers=None, + raw_headers=None, + ) + assert result is None + + +# ── MCPServer Properties ── + + +def test_has_token_exchange_config_true(): + """has_token_exchange_config is True for a fully configured OBO server.""" + server = _obo_server() + assert server.has_token_exchange_config is True + + +def test_has_token_exchange_config_false_wrong_auth_type(): + """has_token_exchange_config is False when auth_type is not oauth2_token_exchange.""" + server = _obo_server(auth_type=MCPAuth.oauth2) + assert server.has_token_exchange_config is False + + +def test_has_token_exchange_config_false_missing_creds(): + """has_token_exchange_config is False when client_id/client_secret missing.""" + server = _obo_server(client_id=None) + assert server.has_token_exchange_config is False + + +def test_has_token_exchange_config_uses_token_url_fallback(): + """has_token_exchange_config is True when token_url is set instead of token_exchange_endpoint.""" + server = _obo_server( + token_exchange_endpoint=None, + token_url="https://idp.example.com/token", + ) + assert server.has_token_exchange_config is True + + +# ── Config Loading ── + + +@pytest.mark.asyncio +async def test_config_loading_token_exchange_fields(): + """load_servers_from_config correctly maps OBO config fields to MCPServer.""" + manager = MCPServerManager() + config = { + "my_obo_server": { + "url": "https://mcp.example.com/mcp", + "transport": "http", + "auth_type": "oauth2_token_exchange", + "client_id": "my-client", + "client_secret": "my-secret", + "token_exchange_endpoint": "https://idp.example.com/oauth2/token", + "audience": "api://my-mcp", + "scopes": ["read", "write"], + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + } + } + await manager.load_servers_from_config(config) + + servers = list(manager.config_mcp_servers.values()) + assert len(servers) == 1 + + server = servers[0] + assert server.auth_type == MCPAuth.oauth2_token_exchange + assert server.token_exchange_endpoint == "https://idp.example.com/oauth2/token" + assert server.audience == "api://my-mcp" + assert server.subject_token_type == "urn:ietf:params:oauth:token-type:jwt" + assert server.client_id == "my-client" + assert server.client_secret == "my-secret" + assert server.scopes == ["read", "write"] + assert server.has_token_exchange_config is True + + +@pytest.mark.asyncio +async def test_config_loading_default_subject_token_type(): + """subject_token_type defaults to access_token when not specified in config.""" + manager = MCPServerManager() + config = { + "obo_defaults": { + "url": "https://mcp.example.com/mcp", + "transport": "http", + "auth_type": "oauth2_token_exchange", + "client_id": "cid", + "client_secret": "csec", + "token_exchange_endpoint": "https://idp.example.com/token", + } + } + await manager.load_servers_from_config(config) + + server = list(manager.config_mcp_servers.values())[0] + assert server.subject_token_type == "urn:ietf:params:oauth:token-type:access_token" + + +@pytest.mark.asyncio +async def test_database_loading_token_exchange_scopes_from_credentials(): + """DB-loaded OBO server credentials retain configured scopes.""" + manager = MCPServerManager() + db_server = LiteLLM_MCPServerTable( + server_id="srv-obo-db", + server_name="obo_db_server", + url="https://mcp.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2_token_exchange, + credentials={ + "client_id": "db-client", + "client_secret": "db-secret", + "token_exchange_endpoint": "https://idp.example.com/oauth2/token", + "audience": "api://db-mcp", + "scopes": ["db.read", "db.write"], + }, + ) + + server = await manager.build_mcp_server_from_table( + db_server, + credentials_are_encrypted=False, + ) + + assert server.auth_type == MCPAuth.oauth2_token_exchange + assert server.client_id == "db-client" + assert server.client_secret == "db-secret" + assert server.token_exchange_endpoint == "https://idp.example.com/oauth2/token" + assert server.audience == "api://db-mcp" + assert server.scopes == ["db.read", "db.write"] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py index 649a08e874..cbea386a69 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py @@ -549,7 +549,11 @@ class TestHookHeaderMergePriority: captured_extra_headers: Dict[str, Any] = {} async def fake_create_mcp_client( - server, mcp_auth_header=None, extra_headers=None, stdio_env=None + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token=None, ): captured_extra_headers["value"] = extra_headers mock_client = MagicMock() @@ -589,7 +593,11 @@ class TestHookHeaderMergePriority: captured_extra_headers: Dict[str, Any] = {} async def fake_create_mcp_client( - server, mcp_auth_header=None, extra_headers=None, stdio_env=None + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token=None, ): captured_extra_headers["value"] = extra_headers mock_client = MagicMock() @@ -635,7 +643,11 @@ class TestHookHeaderMergePriority: captured_extra_headers: Dict[str, Any] = {} async def fake_create_mcp_client( - server, mcp_auth_header=None, extra_headers=None, stdio_env=None + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token=None, ): captured_extra_headers["value"] = extra_headers mock_client = MagicMock() @@ -691,7 +703,11 @@ class TestHookHeaderMergePriority: captured_extra_headers: Dict[str, Any] = {} async def fake_create_mcp_client( - server, mcp_auth_header=None, extra_headers=None, stdio_env=None + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token=None, ): captured_extra_headers["value"] = extra_headers mock_client = MagicMock() @@ -739,7 +755,11 @@ class TestHookHeaderMergePriority: captured_extra_headers: Dict[str, Any] = {} async def fake_create_mcp_client( - server, mcp_auth_header=None, extra_headers=None, stdio_env=None + server, + mcp_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token=None, ): captured_extra_headers["value"] = extra_headers mock_client = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 06f95159c0..a7649502bd 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1228,6 +1228,7 @@ async def test_oauth2_headers_passed_to_mcp_client(): mcp_auth_header=None, extra_headers=None, stdio_env=None, + subject_token=None, ): # Capture the arguments for verification captured_client_args.update( @@ -1236,6 +1237,7 @@ async def test_oauth2_headers_passed_to_mcp_client(): "mcp_auth_header": mcp_auth_header, "extra_headers": extra_headers, "stdio_env": stdio_env, + "subject_token": subject_token, } ) # Return a mock client that doesn't actually connect @@ -2282,6 +2284,144 @@ class TestMCPServerManagerReload: mock_build.assert_awaited_once_with(db_row) assert manager.registry["server-1"] is rebuilt_server + @pytest.mark.asyncio + async def test_skips_server_when_build_from_database_fails(self, caplog): + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + except ImportError: + pytest.skip("MCP server not available") + + manager = MCPServerManager() + timestamp = datetime.utcnow() + healthy_row = _make_db_mcp_server("healthy-server", timestamp) + bad_row = _make_db_mcp_server("bad-server", timestamp) + another_healthy_row = _make_db_mcp_server("another-healthy-server", timestamp) + + healthy_server = MCPServer( + server_id="healthy-server", + name="healthy", + transport=MCPTransport.http, + updated_at=timestamp, + ) + another_healthy_server = MCPServer( + server_id="another-healthy-server", + name="another-healthy", + transport=MCPTransport.http, + updated_at=timestamp, + ) + + async def build_server(db_row): + if db_row.server_id == "bad-server": + raise RuntimeError("transient build failure") + if db_row.server_id == "healthy-server": + return healthy_server + return another_healthy_server + + mock_prisma = MagicMock() + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( + return_value=[healthy_row, bad_row, another_healthy_row] + ) + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=mock_prisma, + ), + patch.object( + manager, + "build_mcp_server_from_table", + AsyncMock(side_effect=build_server), + ), + patch.object(manager, "_maybe_register_openapi_tools", AsyncMock()), + caplog.at_level("ERROR", logger="LiteLLM"), + ): + await manager.reload_servers_from_database() + + assert set(manager.registry) == {"healthy-server", "another-healthy-server"} + assert manager.registry["healthy-server"] is healthy_server + assert manager.registry["another-healthy-server"] is another_healthy_server + assert "Skipping MCP server bad-server" in caplog.text + + @pytest.mark.asyncio + async def test_skips_server_when_openapi_registration_fails(self, caplog): + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + except ImportError: + pytest.skip("MCP server not available") + + manager = MCPServerManager() + timestamp = datetime.utcnow() + healthy_row = _make_db_mcp_server("healthy-server", timestamp) + bad_openapi_row = _make_db_mcp_server("bad-openapi-server", timestamp) + existing_server = MCPServer( + server_id="existing-server", + name="existing", + transport=MCPTransport.http, + updated_at=timestamp, + ) + manager.registry = {existing_server.server_id: existing_server} + + healthy_server = MCPServer( + server_id="healthy-server", + name="healthy", + transport=MCPTransport.http, + updated_at=timestamp, + ) + bad_openapi_server = MCPServer( + server_id="bad-openapi-server", + name="bad-openapi", + transport=MCPTransport.http, + spec_path="https://example.invalid/openapi.json", + updated_at=timestamp, + ) + + async def build_server(db_row): + if db_row.server_id == "healthy-server": + return healthy_server + return bad_openapi_server + + observed_registries = [] + + async def register_openapi_tools(server, **kwargs): + observed_registries.append(set(manager.registry)) + assert kwargs == {"initialize_mapping": False} + if server.server_id == "bad-openapi-server": + raise RuntimeError("blocked address") + + mock_prisma = MagicMock() + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( + return_value=[healthy_row, bad_openapi_row] + ) + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=mock_prisma, + ), + patch.object( + manager, + "build_mcp_server_from_table", + AsyncMock(side_effect=build_server), + ), + patch.object( + manager, + "_maybe_register_openapi_tools", + AsyncMock(side_effect=register_openapi_tools), + ), + caplog.at_level("ERROR", logger="LiteLLM"), + ): + await manager.reload_servers_from_database() + + assert set(manager.registry) == {"healthy-server"} + assert manager.registry["healthy-server"] is healthy_server + assert observed_registries == [ + {"existing-server"}, + {"existing-server"}, + ] + assert "Skipping MCP server bad-openapi-server" in caplog.text + @pytest.mark.asyncio async def test_call_mcp_tool_logs_failure_via_post_call_failure_hook(): @@ -2946,7 +3086,7 @@ async def test_list_tools_with_legacy_db_m2m_server_resolves_oauth2_flow(): """ P1 Regression: list_tools path must apply _resolve_oauth2_flow to legacy DB rows where oauth2_flow is NULL but M2M credentials are present. - + Without this fix, has_client_credentials returns False and the caller's Authorization header is forwarded upstream instead of being blocked. """ @@ -3044,7 +3184,7 @@ async def test_call_tool_empty_extra_headers_returns_none(): """ P2 Regression: When all configured extra_headers are filtered out (e.g. Authorization for M2M), the resulting extra_headers should be None, not {}. - + Downstream code that checks `if extra_headers is None` will behave differently if an empty dict is passed instead. """ @@ -3071,7 +3211,10 @@ async def test_call_tool_empty_extra_headers_returns_none(): extra_headers=["Authorization"], # Will be filtered out for M2M ) - raw_headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"} + raw_headers = { + "Authorization": "Bearer sk-1234", + "Content-Type": "application/json", + } captured_extra_headers = None @@ -3108,8 +3251,8 @@ async def test_call_tool_empty_extra_headers_returns_none(): pass # We only care about the captured headers # With P2 fix: extra_headers should be None (not {}) when all headers filtered - assert captured_extra_headers is None, ( - "P2 API consistency issue: expected None for empty extra_headers, got: " - + str(captured_extra_headers) + assert ( + captured_extra_headers is None + ), "P2 API consistency issue: expected None for empty extra_headers, got: " + str( + captured_extra_headers ) - diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 6cbdcd2820..11e9dbbdd5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -450,7 +450,7 @@ class TestMCPServerManager: captured_extra_headers = None async def capture_create_mcp_client( - server, mcp_auth_header, extra_headers, stdio_env + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None ): # pragma: no cover - helper nonlocal captured_extra_headers captured_extra_headers = extra_headers diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index 957dea22f3..39f3c76722 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -15,6 +15,8 @@ from unittest.mock import AsyncMock, patch import pytest from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_auth_header, + _request_extra_headers, _resolve_param_list, _resolve_ref, build_input_schema, @@ -1011,3 +1013,197 @@ class TestRegisterToolsFromOpenAPI: assert re.match( r"^[a-zA-Z0-9_-]+$", name ), f"fallback tool name {name!r} not sanitized" + + +class TestRequestExtraHeaders: + """Tests for _request_extra_headers ContextVar forwarding in tool_function.""" + + @pytest.mark.asyncio + async def test_extra_headers_forwarded_to_upstream(self): + """Extra headers set via ContextVar are included in the upstream request.""" + operation = {} + func = create_tool_function( + path="/data", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "ok") + mock_client.return_value = async_client + + token = _request_extra_headers.set({"X-TOKEN": "secret-value"}) + try: + result = await func() + finally: + _request_extra_headers.reset(token) + + assert result == "ok" + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent.get("X-TOKEN") == "secret-value" + + @pytest.mark.asyncio + async def test_no_extra_headers_by_default(self): + """Without setting _request_extra_headers, no extra headers are injected.""" + operation = {} + func = create_tool_function( + path="/data", + method="get", + operation=operation, + base_url="https://api.example.com", + headers={"X-Static": "static-value"}, + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "ok") + mock_client.return_value = async_client + + result = await func() + + assert result == "ok" + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent == {"X-Static": "static-value"} + assert "X-TOKEN" not in headers_sent + + @pytest.mark.asyncio + async def test_extra_headers_merged_with_static_headers(self): + """Forwarded headers are passed through alongside non-conflicting static headers.""" + operation = {} + func = create_tool_function( + path="/data", + method="post", + operation=operation, + base_url="https://api.example.com", + headers={"X-Static": "static-value"}, + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("post", "created") + mock_client.return_value = async_client + + token = _request_extra_headers.set({"X-TOKEN": "dynamic-value"}) + try: + result = await func() + finally: + _request_extra_headers.reset(token) + + assert result == "created" + call_args = async_client.post.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent.get("X-Static") == "static-value" + assert headers_sent.get("X-TOKEN") == "dynamic-value" + + @pytest.mark.asyncio + async def test_static_headers_win_over_forwarded_on_conflict(self): + """Static (operator) headers must override forwarded (caller) headers on name conflict.""" + operation = {} + func = create_tool_function( + path="/data", + method="get", + operation=operation, + base_url="https://api.example.com", + headers={"X-Tenant": "operator-tenant"}, + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "ok") + mock_client.return_value = async_client + + token = _request_extra_headers.set({"X-Tenant": "caller-spoofed"}) + try: + result = await func() + finally: + _request_extra_headers.reset(token) + + assert result == "ok" + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent.get("X-Tenant") == "operator-tenant" + assert "caller-spoofed" not in headers_sent.values() + + @pytest.mark.asyncio + async def test_static_headers_win_case_insensitively(self): + """Forwarded header with different casing must not bypass the static-wins rule.""" + operation = {} + func = create_tool_function( + path="/data", + method="get", + operation=operation, + base_url="https://api.example.com", + headers={"X-Tenant": "operator-tenant"}, + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "ok") + mock_client.return_value = async_client + + token = _request_extra_headers.set({"x-tenant": "caller-spoofed"}) + try: + result = await func() + finally: + _request_extra_headers.reset(token) + + assert result == "ok" + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent.get("X-Tenant") == "operator-tenant" + assert "x-tenant" not in headers_sent + assert "caller-spoofed" not in headers_sent.values() + + @pytest.mark.asyncio + async def test_auth_header_still_overrides_extra_headers(self): + """_request_auth_header takes precedence for Authorization over extra headers.""" + operation = {} + func = create_tool_function( + path="/secure", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "secure-data") + mock_client.return_value = async_client + + extra_token = _request_extra_headers.set( + {"Authorization": "Bearer extra", "X-TOKEN": "token-value"} + ) + auth_token = _request_auth_header.set("Bearer byok-credential") + try: + result = await func() + finally: + _request_auth_header.reset(auth_token) + _request_extra_headers.reset(extra_token) + + assert result == "secure-data" + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert headers_sent.get("Authorization") == "Bearer byok-credential" + assert headers_sent.get("X-TOKEN") == "token-value" + + @pytest.mark.asyncio + async def test_extra_headers_not_leaked_between_calls(self): + """After resetting the ContextVar, subsequent calls do not see the headers.""" + operation = {} + func = create_tool_function( + path="/data", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("get", "ok") + mock_client.return_value = async_client + + token = _request_extra_headers.set({"X-TOKEN": "first-call"}) + _request_extra_headers.reset(token) + + await func() + + call_args = async_client.get.call_args + headers_sent = call_args[1]["headers"] + assert "X-TOKEN" not in headers_sent diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 3433e3e6d8..26f04a4abc 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta import httpx import pytest +from fastapi import status import litellm from litellm.proxy._types import ( @@ -31,6 +32,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, + _can_object_call_model, _can_object_call_vector_stores, _check_end_user_budget, _check_team_member_budget, @@ -206,6 +208,52 @@ def test_get_key_object_from_ui_hash_key_invalid(): assert key_object is None +@pytest.mark.parametrize( + "object_type,expected_error_type", + [ + ("key", ProxyErrorTypes.key_model_access_denied), + ("team", ProxyErrorTypes.team_model_access_denied), + ("user", ProxyErrorTypes.user_model_access_denied), + ("org", ProxyErrorTypes.org_model_access_denied), + ("project", ProxyErrorTypes.project_model_access_denied), + ], +) +def test_can_object_call_model_denials_return_forbidden( + object_type, expected_error_type +): + with pytest.raises(ProxyException) as exc_info: + _can_object_call_model( + model="restricted-model", + llm_router=None, + models=["allowed-model"], + object_type=object_type, + ) + + assert exc_info.value.type == expected_error_type + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_can_user_call_model_no_default_models_returns_forbidden(): + from litellm.proxy._types import SpecialModelNames + from litellm.proxy.auth.auth_checks import can_user_call_model + + user_object = LiteLLM_UserTable( + user_id="test-user", + models=[SpecialModelNames.no_default_models.value], + ) + + with pytest.raises(ProxyException) as exc_info: + await can_user_call_model( + model="restricted-model", + llm_router=None, + user_object=user_object, + ) + + assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + @pytest.mark.asyncio async def test_get_key_object_should_reconnect_once_on_db_connection_error(): mock_prisma_client = MagicMock() @@ -906,6 +954,286 @@ def test_can_object_call_model_no_access_to_alias_or_underlying(): assert "my-fake-gpt" in str(exc_info.value.message) +# -- Team-member access-group resolution with team-scoped DB models ----------- + + +def _make_team_scoped_router(team_id: str = "team-a"): + """ + Build a Router whose model_list looks like what the proxy creates for + team-scoped BYOK DB models: the internal model_name is + ``__`` and the public name lives in + ``model_info.team_public_model_name``. Two models belong to the + access group ``fast-models``; one (``mock-power``) does not. + """ + from litellm import Router + + model_list = [ + { + "model_name": f"mock-fast-1_{team_id}_aaa", + "litellm_params": { + "model": "openai/mock-fast-1", + "api_key": "fake", + }, + "model_info": { + "id": f"demo-mock-fast-1-{team_id}", + "team_id": team_id, + "team_public_model_name": "mock-fast-1", + "access_groups": ["fast-models"], + }, + }, + { + "model_name": f"mock-fast-2_{team_id}_bbb", + "litellm_params": { + "model": "openai/mock-fast-2", + "api_key": "fake", + }, + "model_info": { + "id": f"demo-mock-fast-2-{team_id}", + "team_id": team_id, + "team_public_model_name": "mock-fast-2", + "access_groups": ["fast-models"], + }, + }, + { + "model_name": f"mock-power_{team_id}_ccc", + "litellm_params": { + "model": "openai/mock-power", + "api_key": "fake", + }, + "model_info": { + "id": f"demo-mock-power-{team_id}", + "team_id": team_id, + "team_public_model_name": "mock-power", + }, + }, + ] + return Router(model_list=model_list) + + +def test_can_object_call_model_access_group_with_team_id(): + """ + When team_id is passed, _can_object_call_model should resolve + model_info.access_groups for team-scoped DB models and allow + access via group name. + """ + from litellm.proxy.auth.auth_checks import _can_object_call_model + + router = _make_team_scoped_router() + + result = _can_object_call_model( + model="mock-fast-1", + llm_router=router, + models=["fast-models", "mock-power"], + object_type="team", + team_id="team-a", + ) + assert result is True + + +def test_can_object_call_model_access_group_without_team_id_fails(): + """ + Without team_id the router cannot find team-scoped DB models, so + access group resolution fails and the call is denied. + This is the pre-fix behavior. + """ + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import _can_object_call_model + + router = _make_team_scoped_router() + + with pytest.raises(ProxyException): + _can_object_call_model( + model="mock-fast-1", + llm_router=router, + models=["fast-models", "mock-power"], + object_type="team", + # team_id intentionally omitted + ) + + +def test_can_object_call_model_literal_name_with_team_id(): + """ + Literal model name matching should still work when team_id is + passed — no regression from adding team_id. + """ + from litellm.proxy.auth.auth_checks import _can_object_call_model + + router = _make_team_scoped_router() + + result = _can_object_call_model( + model="mock-power", + llm_router=router, + models=["fast-models", "mock-power"], + object_type="team", + team_id="team-a", + ) + assert result is True + + +def test_can_object_call_model_denied_model_with_team_id(): + """ + A model not in the allowed list (by name or access group) should + still be denied even when team_id is passed. + """ + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import _can_object_call_model + + router = _make_team_scoped_router() + + with pytest.raises(ProxyException): + _can_object_call_model( + model="mock-vision", + llm_router=router, + models=["fast-models", "mock-power"], + object_type="team", + team_id="team-a", + ) + + +def test_can_object_call_model_second_group_member_with_team_id(): + """ + Both models in the access group should be reachable, not just + the first one. + """ + from litellm.proxy.auth.auth_checks import _can_object_call_model + + router = _make_team_scoped_router() + + result = _can_object_call_model( + model="mock-fast-2", + llm_router=router, + models=["fast-models"], + object_type="team", + team_id="team-a", + ) + assert result is True + + +@pytest.mark.asyncio +async def test_check_team_member_model_access_with_access_group(): + """ + End-to-end test of _check_team_member_model_access: a member whose + allowed_models contains an access group name should be allowed to + call models in that group for team-scoped DB models. + """ + from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_TeamMembership, + LiteLLM_TeamTable, + UserAPIKeyAuth, + ) + from litellm.proxy.auth.auth_checks import _check_team_member_model_access + + router = _make_team_scoped_router() + team = LiteLLM_TeamTable(team_id="team-a") + token = UserAPIKeyAuth(token="sk-test", user_id="alice", team_id="team-a") + membership = LiteLLM_TeamMembership( + user_id="alice", + team_id="team-a", + litellm_budget_table=LiteLLM_BudgetTable( + allowed_models=["fast-models", "mock-power"], + ), + ) + + with patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + return_value=membership, + ): + # Should not raise — mock-fast-1 is in the fast-models group + await _check_team_member_model_access( + model="mock-fast-1", + team_object=team, + valid_token=token, + llm_router=router, + prisma_client=None, + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + + +@pytest.mark.asyncio +async def test_check_team_member_model_access_denied_model(): + """ + A member with per-member allowed_models should be denied access to + a model that is neither listed by name nor covered by an access group. + """ + from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_TeamMembership, + LiteLLM_TeamTable, + ProxyException, + UserAPIKeyAuth, + ) + from litellm.proxy.auth.auth_checks import _check_team_member_model_access + + router = _make_team_scoped_router() + team = LiteLLM_TeamTable(team_id="team-a") + token = UserAPIKeyAuth(token="sk-test", user_id="alice", team_id="team-a") + membership = LiteLLM_TeamMembership( + user_id="alice", + team_id="team-a", + litellm_budget_table=LiteLLM_BudgetTable( + allowed_models=["fast-models", "mock-power"], + ), + ) + + with patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + return_value=membership, + ): + with pytest.raises(ProxyException) as exc_info: + await _check_team_member_model_access( + model="mock-vision", + team_object=team, + valid_token=token, + llm_router=router, + prisma_client=None, + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + assert exc_info.value.type == ProxyErrorTypes.team_model_access_denied + assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_check_team_member_model_access_no_override_inherits_team(): + """ + When a member has no allowed_models (empty budget table), the function + should return without raising — the team-level check applies instead. + """ + from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_TeamMembership, + LiteLLM_TeamTable, + UserAPIKeyAuth, + ) + from litellm.proxy.auth.auth_checks import _check_team_member_model_access + + router = _make_team_scoped_router() + team = LiteLLM_TeamTable(team_id="team-a") + token = UserAPIKeyAuth(token="sk-test", user_id="bob", team_id="team-a") + membership = LiteLLM_TeamMembership( + user_id="bob", + team_id="team-a", + litellm_budget_table=LiteLLM_BudgetTable(), + ) + + with patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + return_value=membership, + ): + # Should return without raising — no per-member restriction + await _check_team_member_model_access( + model="mock-vision", + team_object=team, + valid_token=token, + llm_router=router, + prisma_client=None, + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + + # Tag Budget Enforcement Tests diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 2d1586f0b1..4ccde85dae 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -140,6 +140,7 @@ async def test_handle_authentication_error_budget_exceeded(): ) assert exc_info.value.type == ProxyErrorTypes.budget_exceeded + assert int(exc_info.value.code) == status.HTTP_429_TOO_MANY_REQUESTS @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/auth/test_team_member_budget.py b/tests/test_litellm/proxy/auth/test_team_member_budget.py index 11a28106e3..b38a953d18 100644 --- a/tests/test_litellm/proxy/auth/test_team_member_budget.py +++ b/tests/test_litellm/proxy/auth/test_team_member_budget.py @@ -306,6 +306,79 @@ async def test_team_member_budget_check_no_team_membership(): assert result is True +@pytest.mark.asyncio +async def test_team_member_budget_check_blocks_regenerated_key_after_old_key_exhausts_budget(): + """Deleting an exhausted key and creating a new key must not reset a user's team budget.""" + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "test"}], + } + + team_object = LiteLLM_TeamTable( + team_id="test-team-1", + team_alias="Test Team", + spend=0.0, + max_budget=None, + ) + # The spend below represents usage accumulated by an earlier key that was + # later deleted. The new key must still be checked against the same + # user/team membership spend instead of receiving a fresh per-key budget. + regenerated_token = UserAPIKeyAuth( + token="new-regenerated-token", + user_id="test-user-1", + team_id="test-team-1", + models=["gpt-3.5-turbo"], + ) + team_membership = LiteLLM_TeamMembership( + user_id="test-user-1", + team_id="test-team-1", + spend=0.0000002, + litellm_budget_table=LiteLLM_BudgetTable( + max_budget=0.0000001, + ), + ) + + mock_request = MagicMock(spec=Request) + mock_prisma_client = MagicMock() + mock_user_api_key_cache = MagicMock() + mock_proxy_logging_obj = MagicMock() + + with ( + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ) as mock_get_team_membership, + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body=request_body, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging_obj, + valid_token=regenerated_token, + request=mock_request, + ) + + mock_get_team_membership.assert_any_await( + user_id="test-user-1", + team_id="test-team-1", + prisma_client=mock_prisma_client, + user_api_key_cache=mock_user_api_key_cache, + proxy_logging_obj=mock_proxy_logging_obj, + ) + assert "Budget has been exceeded" in str(exc_info.value) + assert "test-user-1" in str(exc_info.value) + assert "test-team-1" in str(exc_info.value) + + @pytest.mark.asyncio async def test_team_member_budget_check_personal_key_not_team(): """Test that team member budget check is skipped for personal keys (no team).""" diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 95b3d746c6..50c5f43b21 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -1,6 +1,7 @@ import json import os import sys +from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -9,6 +10,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import pytest +from fastapi import status import litellm import litellm.proxy.proxy_server @@ -178,6 +180,26 @@ async def test_custom_auth_does_not_enforce_key_model_access_by_default(): mock_can_key.assert_not_awaited() +@pytest.mark.asyncio +async def test_post_custom_auth_expired_key_returns_unauthorized(): + expired_token = UserAPIKeyAuth( + token="test_token", + expires=datetime.now() - timedelta(minutes=1), + ) + + with pytest.raises(ProxyException) as exc_info: + await _run_post_custom_auth_checks( + valid_token=expired_token, + request=MagicMock(), + request_data={}, + route="/v1/chat/completions", + parent_otel_span=None, + ) + + assert exc_info.value.type == ProxyErrorTypes.expired_key + assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.asyncio async def test_custom_auth_honors_key_level_model_access_restriction_allowed_with_opt_in(): valid_token = UserAPIKeyAuth(token="test_token", models=["gpt-4o-mini"]) @@ -934,6 +956,7 @@ async def test_proxy_admin_expired_key_from_cache(): assert ( exc_info.value.type == ProxyErrorTypes.expired_key ), f"Expected expired_key error type, got {exc_info.value.type}" + assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED assert "Expired Key" in str( exc_info.value.message ), f"Exception message should mention 'Expired Key', got: {exc_info.value.message}" diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 5c86f9057a..82511fbc55 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -39,6 +39,46 @@ class MockLiteLLMVerificationToken: return {"count": 1} +class MockLiteLLMOrganizationTable: + def __init__(self): + self.update_many_calls: List[Dict[str, Any]] = [] + self.find_many_calls: List[Dict[str, Any]] = [] + self._find_many_results: List[Any] = [] + + def set_find_many_results(self, results: List[Any]): + self._find_many_results = results + + async def find_many(self, where: Dict[str, Any]) -> List[Any]: + self.find_many_calls.append({"where": where}) + return self._find_many_results + + async def update_many( + self, where: Dict[str, Any], data: Dict[str, Any] + ) -> Dict[str, Any]: + self.update_many_calls.append({"where": where, "data": data}) + return {"count": 1} + + +class MockLiteLLMTagTable: + def __init__(self): + self.update_many_calls: List[Dict[str, Any]] = [] + self.find_many_calls: List[Dict[str, Any]] = [] + self._find_many_results: List[Any] = [] + + def set_find_many_results(self, results: List[Any]): + self._find_many_results = results + + async def find_many(self, where: Dict[str, Any]) -> List[Any]: + self.find_many_calls.append({"where": where}) + return self._find_many_results + + async def update_many( + self, where: Dict[str, Any], data: Dict[str, Any] + ) -> Dict[str, Any]: + self.update_many_calls.append({"where": where, "data": data}) + return {"count": 1} + + class MockLiteLLMEndUserTable: def __init__(self): self.find_many_calls: List[Dict[str, Any]] = [] @@ -57,6 +97,8 @@ class MockDB: self.litellm_teammembership = MockLiteLLMTeamMembership() self.litellm_verificationtoken = MockLiteLLMVerificationToken() self.litellm_endusertable = MockLiteLLMEndUserTable() + self.litellm_organizationtable = MockLiteLLMOrganizationTable() + self.litellm_tagtable = MockLiteLLMTagTable() class MockPrismaClient: @@ -459,6 +501,100 @@ def test_reset_budget_for_keys_linked_to_budgets_empty( assert len(calls) == 0 +def test_reset_budget_for_orgs_linked_to_budgets(reset_budget_job, mock_prisma_client): + """ + Test that when a budget tier is reset, orgs linked to that budget + (via budget_id) also get their spend reset. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 100.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-org-budget", + "created_at": now - timedelta(days=30), + }, + ) + + asyncio.run( + reset_budget_job.reset_budget_for_orgs_linked_to_budgets( + budgets_to_reset=[test_budget] + ) + ) + + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 1 + call = calls[0] + assert call["where"]["budget_id"] == {"in": ["30d-org-budget"]} + assert call["where"]["spend"] == {"gt": 0} + assert call["data"]["spend"] == 0 + + +def test_reset_budget_for_orgs_linked_to_budgets_empty( + reset_budget_job, mock_prisma_client +): + """ + Test that when there are no budgets to reset, no update is performed + on the organization table. + """ + asyncio.run( + reset_budget_job.reset_budget_for_orgs_linked_to_budgets(budgets_to_reset=[]) + ) + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 0 + + +def test_reset_budget_for_tags_linked_to_budgets(reset_budget_job, mock_prisma_client): + """ + Test that when a budget tier is reset, tags linked to that budget + (via budget_id) also get their spend reset. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 50.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-tag-budget", + "created_at": now - timedelta(days=30), + }, + ) + + asyncio.run( + reset_budget_job.reset_budget_for_tags_linked_to_budgets( + budgets_to_reset=[test_budget] + ) + ) + + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 1 + call = calls[0] + assert call["where"]["budget_id"] == {"in": ["30d-tag-budget"]} + assert call["where"]["spend"] == {"gt": 0} + assert call["data"]["spend"] == 0 + + +def test_reset_budget_for_tags_linked_to_budgets_empty( + reset_budget_job, mock_prisma_client +): + """ + Test that when there are no budgets to reset, no update is performed + on the tag table. + """ + asyncio.run( + reset_budget_job.reset_budget_for_tags_linked_to_budgets(budgets_to_reset=[]) + ) + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 0 + + @pytest.mark.parametrize( "budget_duration, expected_day, expected_month", [ @@ -618,6 +754,75 @@ def test_budget_table_reset_also_resets_linked_keys( assert calls[0]["data"]["spend"] == 0 +def test_budget_table_reset_also_resets_linked_orgs( + reset_budget_job, mock_prisma_client +): + """ + Integration-style test: when reset_budget_for_litellm_budget_table runs, + it should also reset spend for orgs linked to the expiring budget tiers + (in addition to end-users, team members, and keys). + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 100.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-org-budget", + "created_at": now - timedelta(days=30), + }, + ) + + mock_prisma_client.data["budget"] = [test_budget] + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + calls = mock_prisma_client.db.litellm_organizationtable.update_many_calls + assert len(calls) == 1, ( + "Expected reset_budget_for_litellm_budget_table to also reset orgs " + f"linked to expiring budgets, but got {len(calls)} update_many calls" + ) + assert calls[0]["where"]["budget_id"] == {"in": ["30d-org-budget"]} + assert calls[0]["data"]["spend"] == 0 + + +def test_budget_table_reset_also_resets_linked_tags( + reset_budget_job, mock_prisma_client +): + """ + Integration-style test: when reset_budget_for_litellm_budget_table runs, + it should also reset spend for tags linked to the expiring budget tiers. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 50.0, + "budget_duration": "30d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "30d-tag-budget", + "created_at": now - timedelta(days=30), + }, + ) + + mock_prisma_client.data["budget"] = [test_budget] + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + calls = mock_prisma_client.db.litellm_tagtable.update_many_calls + assert len(calls) == 1, ( + "Expected reset_budget_for_litellm_budget_table to also reset tags " + f"linked to expiring budgets, but got {len(calls)} update_many calls" + ) + assert calls[0]["where"]["budget_id"] == {"in": ["30d-tag-budget"]} + assert calls[0]["data"]["spend"] == 0 + + def test_reset_budget_resets_endusers_with_null_budget_id( reset_budget_job, mock_prisma_client ): @@ -1205,3 +1410,51 @@ def test_reset_budget_for_keys_linked_to_budgets_invalidates_redis_counter(monke counter_cache.in_memory_cache.set_cache.assert_any_call( key="spend:key:sk-linked", value=0.0, ttl=60 ) + + +def test_reset_budget_for_orgs_linked_to_budgets_invalidates_redis_counter(monkeypatch): + """Resetting orgs via budget tier must clear each linked org's counter.""" + counter_cache = _make_counter_invalidation_job(monkeypatch) + + expired_budget = type("B", (), {"budget_id": "budget-1"}) + linked_org = type("Org", (), {"organization_id": "org-acme"}) + + prisma_client = MagicMock() + prisma_client.db.litellm_organizationtable.find_many = AsyncMock( + return_value=[linked_org] + ) + prisma_client.db.litellm_organizationtable.update_many = AsyncMock( + return_value={"count": 1} + ) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + asyncio.run(job.reset_budget_for_orgs_linked_to_budgets([expired_budget])) + + counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:org:org-acme", value=0.0, ttl=60 + ) + counter_cache.redis_cache.async_set_cache.assert_any_await( + key="spend:org:org-acme", value=0.0, ttl=60 + ) + + +def test_reset_budget_for_tags_linked_to_budgets_invalidates_redis_counter(monkeypatch): + """Resetting tags via budget tier must clear each linked tag's counter.""" + counter_cache = _make_counter_invalidation_job(monkeypatch) + + expired_budget = type("B", (), {"budget_id": "budget-1"}) + linked_tag = type("Tag", (), {"tag_name": "tenant-42"}) + + prisma_client = MagicMock() + prisma_client.db.litellm_tagtable.find_many = AsyncMock(return_value=[linked_tag]) + prisma_client.db.litellm_tagtable.update_many = AsyncMock(return_value={"count": 1}) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + asyncio.run(job.reset_budget_for_tags_linked_to_budgets([expired_budget])) + + counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:tag:tenant-42", value=0.0, ttl=60 + ) + counter_cache.redis_cache.async_set_cache.assert_any_await( + key="spend:tag:tenant-42", value=0.0, ttl=60 + ) diff --git a/tests/test_litellm/proxy/db/test_routing_prisma_wrapper.py b/tests/test_litellm/proxy/db/test_routing_prisma_wrapper.py new file mode 100644 index 0000000000..8c3a2b9e2d --- /dev/null +++ b/tests/test_litellm/proxy/db/test_routing_prisma_wrapper.py @@ -0,0 +1,887 @@ +import asyncio +import logging +import os +import sys +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + + +# NOTE: do NOT patch sys.modules["prisma"] file-wide via an autouse fixture. +# Doing so leaks across pytest-xdist test scheduling: when a worker runs a +# routing test, then later runs test_exception_handler.py, the cached MagicMock +# attribute references break `isinstance(e, prisma.errors.X)` in +# `is_database_transport_error`. The two tests below that actually need to +# stub the prisma SDK do so per-test via monkeypatch, which is properly scoped. + + +def _make_wrappers(): + from litellm.proxy.db.prisma_client import PrismaWrapper + + writer_inner = MagicMock(name="writer_prisma") + reader_inner = MagicMock(name="reader_prisma") + writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False) + reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False) + return writer, writer_inner, reader, reader_inner + + +class _FakeActions: + """Stand-in for a Prisma per-model Actions instance (non-callable, has find_many/create).""" + + def __init__(self, name: str): + self._name = name + for method in ( + "find_many", + "find_unique", + "find_first", + "count", + "group_by", + "create", + "update", + "upsert", + "delete", + "delete_many", + "update_many", + ): + setattr(self, method, MagicMock(name=f"{name}.{method}")) + + +def _model_actions_mock(name: str) -> _FakeActions: + return _FakeActions(name) + + +def test_top_level_query_raw_routes_to_reader(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + # query_raw should resolve to the reader's underlying client. + assert routing.query_raw is reader_inner.query_raw + assert routing.query_first is reader_inner.query_first + + +def test_top_level_execute_raw_routes_to_writer(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + # execute_raw, batch_, tx are write-side and must hit the writer. + assert routing.execute_raw is writer_inner.execute_raw + assert routing.batch_ is writer_inner.batch_ + assert routing.tx is writer_inner.tx + + +def test_per_model_reads_route_to_reader_writes_to_writer(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.litellm_usertable = _model_actions_mock("writer_users") + reader_inner.litellm_usertable = _model_actions_mock("reader_users") + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + actions = routing.litellm_usertable + + # Reads → reader actions. + assert actions.find_many is reader_inner.litellm_usertable.find_many + assert actions.find_unique is reader_inner.litellm_usertable.find_unique + assert actions.find_first is reader_inner.litellm_usertable.find_first + assert actions.count is reader_inner.litellm_usertable.count + assert actions.group_by is reader_inner.litellm_usertable.group_by + + # Writes → writer actions. + assert actions.create is writer_inner.litellm_usertable.create + assert actions.update is writer_inner.litellm_usertable.update + assert actions.upsert is writer_inner.litellm_usertable.upsert + assert actions.delete is writer_inner.litellm_usertable.delete + assert actions.update_many is writer_inner.litellm_usertable.update_many + assert actions.delete_many is writer_inner.litellm_usertable.delete_many + + +@pytest.mark.asyncio +async def test_connect_invokes_both_clients(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.connect = AsyncMock() + reader_inner.connect = AsyncMock() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + await routing.connect() + + writer_inner.connect.assert_awaited_once() + reader_inner.connect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_connect_logs_writer_and_reader_success(caplog): + """Successful startup emits a positive INFO confirmation for both writer + and reader so operators can verify connectivity without inspecting the URL + in logs.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.connect = AsyncMock() + reader_inner.connect = AsyncMock() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + await routing.connect() + + messages = [r.getMessage() for r in caplog.records] + assert "[writer] DB connected" in messages + assert "[reader] DB connected" in messages + + +@pytest.mark.asyncio +async def test_disconnect_continues_when_one_side_fails(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.disconnect = AsyncMock(side_effect=RuntimeError("writer down")) + reader_inner.disconnect = AsyncMock() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + with pytest.raises(RuntimeError, match="writer down"): + await routing.disconnect() + + # Reader still attempted even though writer raised. + reader_inner.disconnect.assert_awaited_once() + + +def test_is_connected_reflects_writer_only(): + """is_connected() must NOT depend on reader health — a healthy writer with + a degraded reader should report True so that PrismaClient.connect()'s + health check does not re-trigger a writer reconnect (which only fixes + writer-side problems and would loop indefinitely).""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + writer_inner.is_connected = MagicMock(return_value=True) + reader_inner.is_connected = MagicMock(return_value=True) + assert routing.is_connected() is True + + # Reader down → still True (reader degradation is tracked separately). + reader_inner.is_connected = MagicMock(return_value=False) + assert routing.is_connected() is True + + # Writer down → False. + writer_inner.is_connected = MagicMock(return_value=False) + assert routing.is_connected() is False + + +def test_token_refresh_delegates_to_both_writer_and_reader(): + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.start_token_refresh_task = AsyncMock() + writer.stop_token_refresh_task = AsyncMock() + reader = MagicMock() + reader.start_token_refresh_task = AsyncMock() + reader.stop_token_refresh_task = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + asyncio.run(routing.start_token_refresh_task()) + asyncio.run(routing.stop_token_refresh_task()) + + # Both wrappers get start/stop — each manages its own IAM token. When + # IAM is disabled on a wrapper its task body is a no-op. + writer.start_token_refresh_task.assert_awaited_once() + writer.stop_token_refresh_task.assert_awaited_once() + reader.start_token_refresh_task.assert_awaited_once() + reader.stop_token_refresh_task.assert_awaited_once() + + +def test_routed_actions_falls_back_to_writer_for_unknown_methods(): + from litellm.proxy.db.routing_prisma_wrapper import _RoutedActions + + writer_actions = _model_actions_mock("writer") + writer_actions.some_custom_method = "writer-custom" + reader_actions = _model_actions_mock("reader") + reader_actions.some_custom_method = "reader-custom" + + routed = _RoutedActions(writer_actions, reader_actions, lambda: True) + # Unknown method → defaults to writer (safe fallback for write-like ops). + assert routed.some_custom_method == "writer-custom" + + +def test_routed_actions_respects_should_use_reader_flag(): + """When the routing wrapper marks the reader unavailable, _RoutedActions + must redirect reads to the writer instead — without needing to re-fetch + the actions accessor.""" + from litellm.proxy.db.routing_prisma_wrapper import _RoutedActions + + writer_actions = _model_actions_mock("writer") + reader_actions = _model_actions_mock("reader") + + use_reader = {"value": True} + routed = _RoutedActions(writer_actions, reader_actions, lambda: use_reader["value"]) + + # Reader healthy → reads to reader. + assert routed.find_many is reader_actions.find_many + + # Reader degrades mid-flight → next read goes to writer. + use_reader["value"] = False + assert routed.find_many is writer_actions.find_many + + +# --------------------------------------------------------------------------- +# Reader graceful degradation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connect_swallows_reader_failure_and_falls_back_to_writer(): + """A reader connect failure must NOT abort proxy startup. The wrapper + flips into degraded mode so subsequent reads route to the writer.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.connect = AsyncMock() + reader_inner.connect = AsyncMock(side_effect=RuntimeError("reader unreachable")) + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + # Must not raise — reader failure is non-fatal. + await routing.connect() + + assert routing.reader_unavailable is True + writer_inner.connect.assert_awaited_once() + reader_inner.connect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reads_route_to_writer_when_reader_unavailable(): + """Top-level read methods and per-model reads must fall through to the + writer while the reader is degraded.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, writer_inner, reader, reader_inner = _make_wrappers() + writer_inner.litellm_usertable = _model_actions_mock("writer_users") + reader_inner.litellm_usertable = _model_actions_mock("reader_users") + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + routing._reader_unavailable = True + + # Top-level reads → writer. + assert routing.query_raw is writer_inner.query_raw + assert routing.query_first is writer_inner.query_first + + # Per-model reads → writer actions. + actions = routing.litellm_usertable + assert actions.find_many is writer_inner.litellm_usertable.find_many + assert actions.find_unique is writer_inner.litellm_usertable.find_unique + + +@pytest.mark.asyncio +async def test_recreate_prisma_client_recreates_both_writer_and_reader(): + """Writer reconnect path calls recreate_prisma_client. The routing wrapper + must recreate BOTH clients so a DB-wide event doesn't leave a stale reader.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = False + reader.recreate_prisma_client = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + with patch.dict(os.environ, {"DATABASE_URL_READ_REPLICA": "reader-url"}): + await routing.recreate_prisma_client("writer-url", http_client=None) + + writer.recreate_prisma_client.assert_awaited_once_with( + "writer-url", http_client=None + ) + reader.recreate_prisma_client.assert_awaited_once_with( + "reader-url", http_client=None + ) + assert routing.reader_unavailable is False + + +@pytest.mark.asyncio +async def test_recreate_recovers_reader_after_prior_degradation(): + """If a previous connect/recreate degraded the reader, a successful + recreate must clear the flag so reads start hitting the reader again.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = False + reader.recreate_prisma_client = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + routing._reader_unavailable = True + + with patch.dict(os.environ, {"DATABASE_URL_READ_REPLICA": "reader-url"}): + await routing.recreate_prisma_client("writer-url") + + assert routing.reader_unavailable is False + + +@pytest.mark.asyncio +async def test_recreate_degrades_reader_if_reader_recreate_fails(): + """If the reader recreate fails, writer recreate still succeeds and the + routing wrapper degrades (does not raise).""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = False + reader.recreate_prisma_client = AsyncMock( + side_effect=RuntimeError("reader still down") + ) + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + with patch.dict(os.environ, {"DATABASE_URL_READ_REPLICA": "reader-url"}): + # Must not raise — writer was recreated, reader is best-effort. + await routing.recreate_prisma_client("writer-url") + + writer.recreate_prisma_client.assert_awaited_once() + assert routing.reader_unavailable is True + + +@pytest.mark.asyncio +async def test_recreate_degrades_reader_when_replica_url_missing(): + """Non-IAM reader needs DATABASE_URL_READ_REPLICA. If it's missing + (configuration drift), the wrapper degrades instead of raising.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = False + reader.recreate_prisma_client = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + # Ensure env var is absent. + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("DATABASE_URL_READ_REPLICA", None) + await routing.recreate_prisma_client("writer-url") + + writer.recreate_prisma_client.assert_awaited_once() + reader.recreate_prisma_client.assert_not_awaited() + assert routing.reader_unavailable is True + + +@pytest.mark.asyncio +async def test_recreate_iam_reader_refreshes_token(): + """IAM-enabled readers must refresh their token (reader has its own parsed + endpoint) and pass the fresh URL to recreate_prisma_client.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = True + reader.get_rds_iam_token = MagicMock(return_value="postgresql://u:fresh@h:5432/db") + reader.recreate_prisma_client = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + await routing.recreate_prisma_client("writer-url") + + reader.get_rds_iam_token.assert_called_once() + reader.recreate_prisma_client.assert_awaited_once_with( + "postgresql://u:fresh@h:5432/db", http_client=None + ) + assert routing.reader_unavailable is False + + +@pytest.mark.asyncio +async def test_recreate_degrades_when_iam_token_generation_returns_none(): + """If `get_rds_iam_token` returns None (e.g. AWS-side failure), the wrapper + must degrade rather than crash — this exercises the explicit `raise + RuntimeError` inside `_recreate_reader`'s IAM branch.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer = MagicMock() + writer.recreate_prisma_client = AsyncMock() + reader = MagicMock() + reader.iam_token_db_auth = True + reader.get_rds_iam_token = MagicMock(return_value=None) + reader.recreate_prisma_client = AsyncMock() + + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + await routing.recreate_prisma_client("writer-url") + + writer.recreate_prisma_client.assert_awaited_once() + reader.recreate_prisma_client.assert_not_awaited() + assert routing.reader_unavailable is True + + +def test_writer_and_reader_properties_expose_underlying_wrappers(): + """The `writer` and `reader` properties are used by PrismaClient.writer_db + to smoke-test the writer specifically during reconnect — they must return + the exact wrappers passed in.""" + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + writer, _, reader, _ = _make_wrappers() + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + assert routing.writer is writer + assert routing.reader is reader + + +def test_per_model_accessor_falls_back_when_reader_lacks_attr(): + """If the reader Prisma client somehow lacks a model accessor that the + writer has (older client / partial mock), the wrapper must fall back to + the writer accessor instead of raising AttributeError to the caller.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + # Plain class with only the accessor set on the writer side. Using a real + # class instead of MagicMock so attribute access raises AttributeError + # naturally instead of auto-creating mock attributes. + class _PartialPrisma: + pass + + writer_inner = _PartialPrisma() + writer_inner.litellm_usertable = _model_actions_mock("writer_users") + reader_inner = _PartialPrisma() # deliberately missing litellm_usertable + + writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False) + reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False) + routing = RoutingPrismaWrapper(writer=writer, reader=reader) + + actions = routing.litellm_usertable + # Falls back to the writer's accessor verbatim — not a _RoutedActions wrapper. + assert actions is writer_inner.litellm_usertable + + +@pytest.mark.asyncio +async def test_writer_recreate_passes_http_client_through(monkeypatch): + """When PrismaClient is constructed with an http_client, recreate must + forward it to the new Prisma() so connection settings persist across + reconnects.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + captured_kwargs: Dict[str, Any] = {} + + class FakePrisma: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + async def connect(self): + return None + + fake_module = MagicMock() + fake_module.Prisma = FakePrisma + monkeypatch.setitem(sys.modules, "prisma", fake_module) + + writer = PrismaWrapper(original_prisma=MagicMock(), iam_token_db_auth=False) + sentinel_http = object() + await writer.recreate_prisma_client( + "postgresql://u:p@h:5432/db", http_client=sentinel_http + ) + + assert captured_kwargs == {"http": sentinel_http} + + +# --------------------------------------------------------------------------- +# IAM endpoint parsing + reader IAM refresh +# --------------------------------------------------------------------------- + + +def test_parse_iam_endpoint_from_url_extracts_all_fields(): + from litellm.proxy.db.prisma_client import parse_iam_endpoint_from_url + + ep = parse_iam_endpoint_from_url( + "postgresql://litellm_user:initial-token@aurora-reader.example.com:6543/litellm?schema=public" + ) + assert ep.host == "aurora-reader.example.com" + assert ep.port == "6543" + assert ep.user == "litellm_user" + assert ep.name == "litellm" + assert ep.schema == "public" + + +def test_parse_iam_endpoint_defaults_port_to_5432_and_skips_schema(): + from litellm.proxy.db.prisma_client import parse_iam_endpoint_from_url + + ep = parse_iam_endpoint_from_url("postgresql://u@host/dbname") + assert ep.host == "host" + assert ep.port == "5432" + assert ep.user == "u" + assert ep.name == "dbname" + assert ep.schema is None + + +def test_parse_iam_endpoint_rejects_url_without_user_or_dbname(): + from litellm.proxy.db.prisma_client import parse_iam_endpoint_from_url + + with pytest.raises(ValueError, match="missing host or username"): + parse_iam_endpoint_from_url("postgresql://host:5432/db") + with pytest.raises(ValueError, match="missing database name"): + parse_iam_endpoint_from_url("postgresql://u@host:5432/") + + +def test_iam_endpoint_build_url_inserts_token_verbatim(): + from litellm.proxy.db.prisma_client import IAMEndpoint + + # `generate_iam_auth_token` already URL-encodes the presigned token, so + # `build_url` must NOT encode again — double-encoding turned `%3D` into + # `%253D` and broke RDS auth on the reader path. + ep = IAMEndpoint(host="h", port="5432", user="u", name="db", schema="public") + pre_encoded_token = "token%2Fwith%3Fweird%26chars%3Dyes" + url = ep.build_url(pre_encoded_token) + assert url == f"postgresql://u:{pre_encoded_token}@h:5432/db?schema=public" + # Sanity check: no `%25` (the encoding of `%`), confirming we didn't re-encode. + assert "%25" not in url + + +@pytest.mark.asyncio +async def test_iam_refresh_logs_carry_log_prefix(caplog): + """When `log_prefix` is set on a PrismaWrapper, every IAM-related log + line emitted by that wrapper must start with the prefix so writer and + reader can be told apart in interleaved output.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + wrapper = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + log_prefix="[reader]", + ) + + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + await wrapper.start_token_refresh_task() + # Loop emits "RDS IAM token refresh loop started..." on first tick. + # Cancel immediately so the loop body runs once and we can assert. + await wrapper.stop_token_refresh_task() + + messages = [r.getMessage() for r in caplog.records] + # Both start and stop notifications carry the prefix. + assert any( + m.startswith("[reader] Started RDS IAM token proactive refresh") + for m in messages + ) + assert any( + m.startswith("[reader] Stopped RDS IAM token refresh background task") + for m in messages + ) + + +def test_get_rds_iam_token_returns_none_when_iam_disabled(): + """`get_rds_iam_token` short-circuits to None when iam_token_db_auth is + False — covers the early-return guard at the top of the method.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + wrapper = PrismaWrapper(original_prisma=MagicMock(), iam_token_db_auth=False) + assert wrapper.get_rds_iam_token() is None + + +@pytest.mark.asyncio +async def test_getattr_does_not_block_inside_running_loop_on_expired_token(monkeypatch): + """When `__getattr__` runs inside a running event loop and the IAM token + is expired, it MUST schedule the refresh as a background task and return + immediately. The previous `run_coroutine_threadsafe` + `future.result()` + pattern deadlocks the loop (loop thread blocks waiting for a coroutine + that needs the loop to run) and times out at 30s — exactly what was + breaking the reader on first query.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + # Stale URL — `is_token_expired` returns True because the password isn't + # a parseable IAM token, so we exercise the expired branch. + monkeypatch.setenv( + "DATABASE_URL_READ_REPLICA", + "postgresql://reader:placeholder@reader.aurora.local:5432/litellm", + ) + + inner = MagicMock() + inner.query_raw = MagicMock(name="query_raw_attr") + + wrapper = PrismaWrapper( + original_prisma=inner, + iam_token_db_auth=True, + db_url_env_var="DATABASE_URL_READ_REPLICA", + ) + + # Replace the heavy refresh coroutine with a no-op AsyncMock so we can + # observe whether it was scheduled without actually doing the recreate. + refresh_calls = {"count": 0} + + async def fake_refresh(): + refresh_calls["count"] += 1 + + monkeypatch.setattr(wrapper, "_safe_refresh_token", fake_refresh) + + # Direct attribute access from inside this async test runs __getattr__ + # on the loop thread, exercising the in-loop branch. If the previous + # `run_coroutine_threadsafe` + `future.result()` pattern were back, this + # line would deadlock the loop and the test would hang (and pytest's + # per-test timeout would catch it). + attr = wrapper.query_raw + # Yield once so the scheduled refresh task gets a chance to run. + await asyncio.sleep(0) + + assert attr is inner.query_raw + assert refresh_calls["count"] == 1 + + +def test_writer_get_rds_iam_token_defaults_port_when_unset(monkeypatch): + """When DATABASE_PORT is unset, the writer must default to the Postgres + standard port instead of passing `None` through. Passing None to + `generate_iam_auth_token` makes botocore embed the literal string + \"None\" in the presigned URL during signing and crashes with + `ValueError: Port could not be cast to integer value as 'None'`.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + monkeypatch.setenv("DATABASE_HOST", "writer.aurora.local") + monkeypatch.delenv("DATABASE_PORT", raising=False) + monkeypatch.setenv("DATABASE_USER", "litellm") + monkeypatch.setenv("DATABASE_NAME", "litellm") + monkeypatch.delenv("DATABASE_SCHEMA", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + + captured: Dict[str, Any] = {} + + def fake_generate(db_host=None, db_port=None, db_user=None): + captured["port"] = db_port + return "TOKEN" + + fake_module = MagicMock() + fake_module.generate_iam_auth_token = fake_generate + monkeypatch.setitem(sys.modules, "litellm.proxy.auth.rds_iam_token", fake_module) + + writer = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + ) + new_url = writer.get_rds_iam_token() + + assert captured["port"] == "5432" # default applied, NOT None + assert ":5432/litellm" in (new_url or "") + + +def test_writer_get_rds_iam_token_uses_database_host_env_vars(monkeypatch): + """Writer's IAM path (no iam_endpoint configured) reads host/port/user/db + from the legacy DATABASE_HOST/PORT/USER/NAME env vars and writes the URL + back to DATABASE_URL — this is the pre-read-replica behavior the patch + must preserve.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + monkeypatch.setenv("DATABASE_HOST", "writer.aurora.local") + monkeypatch.setenv("DATABASE_PORT", "5432") + monkeypatch.setenv("DATABASE_USER", "litellm") + monkeypatch.setenv("DATABASE_NAME", "litellm") + monkeypatch.setenv("DATABASE_SCHEMA", "public") + monkeypatch.delenv("DATABASE_URL", raising=False) + + captured: Dict[str, Any] = {} + + def fake_generate(db_host=None, db_port=None, db_user=None): + captured["host"] = db_host + captured["port"] = db_port + captured["user"] = db_user + return "WRITER-TOKEN" + + fake_module = MagicMock() + fake_module.generate_iam_auth_token = fake_generate + monkeypatch.setitem(sys.modules, "litellm.proxy.auth.rds_iam_token", fake_module) + + writer = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + # No iam_endpoint → legacy DATABASE_HOST/etc. path. + ) + new_url = writer.get_rds_iam_token() + + assert captured == { + "host": "writer.aurora.local", + "port": "5432", + "user": "litellm", + } + assert new_url == ( + "postgresql://litellm:WRITER-TOKEN@writer.aurora.local:5432/litellm?schema=public" + ) + # Writer updates its own env var (DATABASE_URL by default), not the reader's. + assert os.environ["DATABASE_URL"] == new_url + + +def test_reader_iam_refresh_uses_parsed_endpoint(monkeypatch): + """The reader generates fresh tokens against its parsed endpoint and + writes the new URL to DATABASE_URL_READ_REPLICA — not DATABASE_URL.""" + from litellm.proxy.db.prisma_client import IAMEndpoint, PrismaWrapper + + # Pre-seed env vars so we can prove the reader does NOT touch DATABASE_URL. + monkeypatch.setenv("DATABASE_URL", "writer-url-untouched") + monkeypatch.setenv("DATABASE_URL_READ_REPLICA", "stale-reader-url") + + captured: Dict[str, Any] = {} + + def fake_generate(db_host=None, db_port=None, db_user=None): + captured["host"] = db_host + captured["port"] = db_port + captured["user"] = db_user + return "FRESH-TOKEN" + + fake_module = MagicMock() + fake_module.generate_iam_auth_token = fake_generate + monkeypatch.setitem(sys.modules, "litellm.proxy.auth.rds_iam_token", fake_module) + + endpoint = IAMEndpoint( + host="reader.aurora.local", + port="5432", + user="lit", + name="litellm", + schema=None, + ) + reader = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + db_url_env_var="DATABASE_URL_READ_REPLICA", + iam_endpoint=endpoint, + recreate_uses_datasource=True, + ) + + new_url = reader.get_rds_iam_token() + + # IAM token generator was called with the reader's parsed endpoint, not + # the writer's DATABASE_HOST/PORT/USER env vars. + assert captured == { + "host": "reader.aurora.local", + "port": "5432", + "user": "lit", + } + assert new_url is not None + assert new_url.startswith( + "postgresql://lit:FRESH-TOKEN@reader.aurora.local:5432/litellm" + ) + # The reader updates its OWN env var; writer's DATABASE_URL is left alone. + assert os.environ["DATABASE_URL_READ_REPLICA"] == new_url + assert os.environ["DATABASE_URL"] == "writer-url-untouched" + + +@pytest.mark.asyncio +async def test_reader_recreate_uses_datasource_override(monkeypatch): + """Reader recreate must pass `datasource={"url": ...}` to Prisma() — Prisma + only auto-reads DATABASE_URL, so without the override the new reader URL + would be silently ignored.""" + from litellm.proxy.db.prisma_client import IAMEndpoint, PrismaWrapper + + captured_kwargs: Dict[str, Any] = {} + + class FakePrisma: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + async def connect(self): + return None + + fake_module = MagicMock() + fake_module.Prisma = FakePrisma + monkeypatch.setitem(sys.modules, "prisma", fake_module) + + reader = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + db_url_env_var="DATABASE_URL_READ_REPLICA", + iam_endpoint=IAMEndpoint(host="h", port="5432", user="u", name="db"), + recreate_uses_datasource=True, + ) + + await reader.recreate_prisma_client( + "postgresql://u:newtoken@h:5432/db", http_client=None + ) + + assert captured_kwargs == { + "datasource": {"url": "postgresql://u:newtoken@h:5432/db"} + } + + +@pytest.mark.asyncio +async def test_writer_recreate_does_not_use_datasource(monkeypatch): + """Writer keeps relying on Prisma reading DATABASE_URL from env — datasource + override must NOT leak into the writer path (would override the freshly + rotated env var).""" + from litellm.proxy.db.prisma_client import PrismaWrapper + + captured_kwargs: Dict[str, Any] = {} + + class FakePrisma: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + async def connect(self): + return None + + fake_module = MagicMock() + fake_module.Prisma = FakePrisma + monkeypatch.setitem(sys.modules, "prisma", fake_module) + + writer = PrismaWrapper( + original_prisma=MagicMock(), + iam_token_db_auth=True, + ) + + await writer.recreate_prisma_client( + "postgresql://u:newtoken@h:5432/db", http_client=None + ) + + assert "datasource" not in captured_kwargs + + +def test_prisma_client_init_falls_back_to_writer_when_reader_iam_token_fails( + monkeypatch, caplog +): + """A transient AWS STS error (or any other failure) during the reader + IAM token mint must NOT abort proxy startup. The reader is opt-in, so + `PrismaClient.__init__` should log a warning and fall back to the + writer-only `PrismaWrapper`. The runtime contract in + `RoutingPrismaWrapper.connect` already says reader-side failures are + non-fatal — but that code never runs if construction throws first.""" + from litellm.proxy.db.prisma_client import PrismaWrapper + from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper + + monkeypatch.setenv("IAM_TOKEN_DB_AUTH", "true") + monkeypatch.setenv( + "DATABASE_URL_READ_REPLICA", + "postgresql://reader_user@reader.aurora.local:5432/litellm", + ) + + class FakePrisma: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def connect(self): + return None + + fake_prisma_module = MagicMock() + fake_prisma_module.Prisma = FakePrisma + monkeypatch.setitem(sys.modules, "prisma", fake_prisma_module) + + fake_iam_module = MagicMock() + + def boom(**_kwargs): + raise RuntimeError("simulated AWS STS hiccup") + + fake_iam_module.generate_iam_auth_token = boom + monkeypatch.setitem( + sys.modules, "litellm.proxy.auth.rds_iam_token", fake_iam_module + ) + + from litellm.proxy.utils import PrismaClient + + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + client = PrismaClient( + database_url="postgresql://writer@writer.aurora.local:5432/litellm", + proxy_logging_obj=MagicMock(), + ) + + # Construction did not raise, and the proxy is in writer-only mode — + # NOT a RoutingPrismaWrapper, so reads will go to the writer. + assert isinstance(client.db, PrismaWrapper) + assert not isinstance(client.db, RoutingPrismaWrapper) + # And the operator gets a clear warning. + assert any( + "Failed to initialize read replica Prisma client" in r.getMessage() + for r in caplog.records + ) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index 2418d7af04..6e027fa494 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -8,7 +8,9 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, + effective_skip_tool_message_for_guardrail, openai_messages_without_system, + openai_messages_without_tool, ) from litellm.llms.openai.chat.guardrail_translation.handler import ( OpenAIChatCompletionsHandler, @@ -180,6 +182,136 @@ class TestUnifiedLLMGuardrails: } assert "system" in roles + class TestSkipToolMessageForChatCompletions: + def test_openai_messages_without_tool(self): + msgs = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "content": "tool result", "tool_call_id": "call_1"}, + ] + out = openai_messages_without_tool(msgs) + assert len(out) == 2 + assert all(m["role"] != "tool" for m in out) + assert msgs[2]["content"] == "tool result" + + def test_effective_skip_tool_respects_per_guardrail_over_global( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + class G: + skip_tool_message_in_guardrail = False + + assert effective_skip_tool_message_for_guardrail(G()) is False + + class G2: + skip_tool_message_in_guardrail = None + + assert effective_skip_tool_message_for_guardrail(G2()) is True + + @pytest.mark.asyncio + async def test_openai_handler_skips_tool_in_guardrail_inputs(self, monkeypatch): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_tool_message_in_guardrail = None + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": "secret tool result", + "tool_call_id": "call_1", + }, + ], + "model": "gpt-4o", + } + + handler = OpenAIChatCompletionsHandler() + await handler.process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert "secret tool result" not in captured["inputs"]["texts"] + sm = captured["inputs"].get("structured_messages") or [] + assert all(m.get("role") != "tool" for m in sm) + assert data["messages"][2]["content"] == "secret tool result" + + @pytest.mark.asyncio + async def test_openai_handler_per_guardrail_skip_tool_false_overrides_global( + self, monkeypatch + ): + monkeypatch.setattr( + litellm, "skip_tool_message_in_guardrail", True, raising=False + ) + + captured = {} + + class MockGuardrail: + skip_tool_message_in_guardrail = False + + async def apply_guardrail( + self, inputs, request_data, input_type, logging_obj=None + ): + captured["inputs"] = inputs + return inputs + + data = { + "messages": [ + {"role": "user", "content": "u"}, + {"role": "tool", "content": "tr", "tool_call_id": "call_1"}, + ], + } + + await OpenAIChatCompletionsHandler().process_input_messages( + data=data, + guardrail_to_apply=MockGuardrail(), + litellm_logging_obj=None, + ) + + assert "tr" in captured["inputs"]["texts"] + roles = { + m.get("role") + for m in (captured["inputs"].get("structured_messages") or []) + } + assert "tool" in roles + class TestAsyncPreCallHook: @pytest.mark.asyncio async def test_uses_mcp_event_type(self): diff --git a/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py index e7fec2c527..00ed7e8cd6 100644 --- a/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py +++ b/tests/test_litellm/proxy/hooks/test_dynamic_rate_limiter_v3.py @@ -1677,3 +1677,98 @@ async def test_async_log_success_event_uses_team_priority_from_auth_metadata(): assert ( "default_pool" not in priority_keys[0] ), f"Priority key should NOT use 'default_pool', should use team's priority. Got: {priority_keys[0]}" + + +@pytest.mark.asyncio +async def test_priority_429_includes_model_name_and_configured_limits(): + """ + The priority-based 429 should tell operators which model was hit and what + the model's configured TPM/RPM are, so they can decide whether to tune the + priority allocation or the model limits. + + Regression test for the previous message that read: + "Priority-based rate limit exceeded. Priority: prod, + Rate limit type: tokens, Remaining: -664145, + Model saturation: 86.3%" + -- with no indication of which model was hit. + """ + from fastapi import HTTPException + + os.environ["LITELLM_LICENSE"] = "test-license-key" + litellm.priority_reservation = {"prod": 0.5} + + dual_cache = DualCache() + handler = DynamicRateLimitHandler(internal_usage_cache=dual_cache) + + model = "gpt-4o-test" + total_tpm = 1_000_000 + total_rpm = 10_000 + + llm_router = Router( + model_list=[ + { + "model_name": model, + "litellm_params": { + "model": "gpt-3.5-turbo", + "api_key": "test-key", + "api_base": "test-base", + "tpm": total_tpm, + "rpm": total_rpm, + }, + } + ] + ) + handler.update_variables(llm_router=llm_router) + + user = UserAPIKeyAuth() + user.metadata = {"priority": "prod"} + user.user_id = "prod_user" + + model_group_info = handler.llm_router.get_model_group_info(model_group=model) + + # Force the atomic check+increment to return OVER_LIMIT for the + # priority_model descriptor. saturation=0.95 keeps us above the + # default saturation threshold so priority limits are enforced. + over_limit_response = { + "overall_code": "OVER_LIMIT", + "statuses": [ + { + "code": "OVER_LIMIT", + "descriptor_key": "priority_model", + "rate_limit_type": "tokens", + "limit_remaining": -664145, + "current_limit": int(total_tpm * 0.5), + } + ], + } + + with patch.object( + handler.v3_limiter, + "atomic_check_and_increment_by_n", + new=AsyncMock(return_value=over_limit_response), + ): + with pytest.raises(HTTPException) as exc_info: + await handler._check_rate_limits( + model=model, + model_group_info=model_group_info, + user_api_key_dict=user, + priority="prod", + saturation=0.95, + data={"model": model}, + ) + + assert exc_info.value.status_code == 429 + detail = exc_info.value.detail + assert isinstance(detail, dict) + error_msg = detail["error"] + + # New fields added by this change -- the whole point of the fix. + assert f"Model: {model}" in error_msg, error_msg + assert f"Model TPM: {total_tpm}" in error_msg, error_msg + assert f"Model RPM: {total_rpm}" in error_msg, error_msg + + # Existing fields must still be present (no regression). + assert "Priority-based rate limit exceeded" in error_msg, error_msg + assert "Priority: prod" in error_msg, error_msg + assert "Rate limit type: tokens" in error_msg, error_msg + assert "Model saturation:" in error_msg, error_msg diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index ad53e87e55..ad89301280 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -4,6 +4,7 @@ import pytest from fastapi import HTTPException from litellm.proxy._types import ( + LiteLLM_UserTable, LitellmUserRoles, NewUserRequest, NewUserResponse, @@ -16,8 +17,8 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import ( _process_group_patch_operations, create_group, create_user, + get_users, get_service_provider_config, - patch_group, patch_user, update_group, update_user, @@ -259,6 +260,124 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp ) +@pytest.mark.asyncio +async def test_get_users_filters_username_by_exposed_scim_username_for_okta(mocker): + """ + Okta deprovisioning first locates a user with `userName eq ""`. + LiteLLM exposes SCIM userName from user_email, so the lookup must match + user_email even when the internal user_id is a UUID. + """ + user = LiteLLM_UserTable( + user_id="internal-user-id", + user_email="okta.user@example.com", + user_alias="Okta User", + teams=[], + metadata={}, + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[user]) + mock_prisma_client.db.litellm_usertable.count = AsyncMock(return_value=1) + + 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.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock( + return_value=SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + id="internal-user-id", + userName="okta.user@example.com", + emails=[SCIMUserEmail(value="okta.user@example.com")], + ) + ), + ) + + response = await get_users( + startIndex=1, + count=10, + filter='userName eq "okta.user@example.com"', + ) + + expected_where = { + "OR": [ + {"user_email": "okta.user@example.com"}, + {"user_id": "okta.user@example.com"}, + ] + } + mock_prisma_client.db.litellm_usertable.find_many.assert_awaited_once_with( + where=expected_where, + skip=0, + take=10, + order={"created_at": "desc"}, + ) + mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with( + where=expected_where + ) + assert response.totalResults == 1 + assert response.Resources[0].id == "internal-user-id" + + +@pytest.mark.asyncio +async def test_get_users_filters_email_value_by_user_email(mocker): + """ + SCIM clients can locate users with `emails.value eq ""`; keep that + filter as a direct user_email lookup alongside the userName fallback query. + """ + user = LiteLLM_UserTable( + user_id="internal-user-id", + user_email="scim.user@example.com", + user_alias="SCIM User", + teams=[], + metadata={}, + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=[user]) + mock_prisma_client.db.litellm_usertable.count = AsyncMock(return_value=1) + + 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.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock( + return_value=SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + id="internal-user-id", + userName="scim.user@example.com", + emails=[SCIMUserEmail(value="scim.user@example.com")], + ) + ), + ) + + response = await get_users( + startIndex=1, + count=10, + filter='emails.value eq "scim.user@example.com"', + ) + + expected_where = {"user_email": "scim.user@example.com"} + mock_prisma_client.db.litellm_usertable.find_many.assert_awaited_once_with( + where=expected_where, + skip=0, + take=10, + order={"created_at": "desc"}, + ) + mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with( + where=expected_where + ) + assert response.totalResults == 1 + assert response.Resources[0].id == "internal-user-id" + + @pytest.mark.asyncio async def test_handle_existing_user_by_email_no_email(mocker): """Should return None when new_user_request has no email""" @@ -1337,7 +1456,7 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true( ) # Execute the create_group function - should succeed - result = await create_group(group=scim_group) + await create_group(group=scim_group) # Verify users were created assert mock_create_user.call_count == 2 diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 54fbac1264..dc983aa26f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -83,41 +83,100 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): mock_prisma = MagicMock() mock_prisma.db = MagicMock() - # query_raw returns list of dicts (pre-aggregated by GROUP BY) + # query_raw now returns rollup rows produced by GROUPING SETS, each + # tagged with its grouping level via GROUPING_ID(). The dispatcher + # places each row directly in its bucket without Python-side summing. + # GROUPING_ID values for relevant levels (date, api_key, model, + # model_group, custom_llm_provider, mcp, endpoint): + # () grand total = 127 + # (date) = 63 + # (date, endpoint) = 62 + # (date, endpoint, api_key) = 30 + base = { + "model": None, + "model_group": None, + "custom_llm_provider": None, + "mcp_namespaced_tool_name": None, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "failed_requests": 0, + } mock_rows = [ + # (date, endpoint) — rolls up across api_keys and models { + **base, "date": "2024-01-01", "endpoint": "/v1/chat/completions", - "api_key": "key-1", - "model": "gpt-4", - "model_group": None, - "custom_llm_provider": "openai", - "mcp_namespaced_tool_name": None, + "api_key": None, + "group_level": 62, "spend": 15.0, "prompt_tokens": 150, "completion_tokens": 75, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, "api_requests": 2, "successful_requests": 2, - "failed_requests": 0, }, { + **base, "date": "2024-01-01", "endpoint": "/v1/embeddings", - "api_key": "key-2", - "model": "text-embedding-ada-002", - "model_group": None, - "custom_llm_provider": "openai", - "mcp_namespaced_tool_name": None, + "api_key": None, + "group_level": 62, "spend": 3.0, "prompt_tokens": 30, "completion_tokens": 0, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, "api_requests": 1, "successful_requests": 1, - "failed_requests": 0, + }, + # (date, endpoint, api_key) — populates the per-key sub-bucket + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/chat/completions", + "api_key": "key-1", + "group_level": 30, + "spend": 15.0, + "prompt_tokens": 150, + "completion_tokens": 75, + "api_requests": 2, + "successful_requests": 2, + }, + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/embeddings", + "api_key": "key-2", + "group_level": 30, + "spend": 3.0, + "prompt_tokens": 30, + "completion_tokens": 0, + "api_requests": 1, + "successful_requests": 1, + }, + # (date) — per-date totals + { + **base, + "date": "2024-01-01", + "endpoint": None, + "api_key": None, + "group_level": 63, + "spend": 18.0, + "prompt_tokens": 180, + "completion_tokens": 75, + "api_requests": 3, + "successful_requests": 3, + }, + # () — grand total + { + **base, + "date": None, + "endpoint": None, + "api_key": None, + "group_level": 127, + "spend": 18.0, + "prompt_tokens": 180, + "completion_tokens": 75, + "api_requests": 3, + "successful_requests": 3, }, ] @@ -449,24 +508,43 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): mock_prisma = MagicMock() mock_prisma.db = MagicMock() - # query_raw returns list of dicts (pre-aggregated by GROUP BY) + # GROUPING SETS rollup rows. The api_key metadata lookup is driven + # by any non-NULL api_key in the result set, so the (date, endpoint, + # api_key) row at level 30 is what ensures get_api_key_metadata is + # called for "deleted-key-hash". + base = { + "model": None, + "model_group": None, + "custom_llm_provider": None, + "mcp_namespaced_tool_name": None, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "failed_requests": 0, + } mock_rows = [ { + **base, "date": "2024-01-01", "endpoint": "/v1/chat/completions", + "api_key": None, + "group_level": 62, + "spend": 10.0, + "prompt_tokens": 100, + "completion_tokens": 50, + "api_requests": 1, + "successful_requests": 1, + }, + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/chat/completions", "api_key": "deleted-key-hash", - "model": "gpt-4", - "model_group": None, - "custom_llm_provider": "openai", - "mcp_namespaced_tool_name": None, + "group_level": 30, "spend": 10.0, "prompt_tokens": 100, "completion_tokens": 50, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, "api_requests": 1, "successful_requests": 1, - "failed_requests": 0, }, ] diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 24b8e1595b..f0909afcbf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1553,6 +1553,98 @@ class TestTemporaryMCPSessionEndpoints: assert "permission" in str(exc_info.value) + @pytest.mark.asyncio + async def test_mcp_oauth_user_api_key_auth_falls_back_to_token_cookie(self): + """ + When the Authorization header is absent but a valid 'token' cookie is + present (browser navigation), _mcp_oauth_user_api_key_auth should + decode the cookie JWT and authenticate via the API key stored in it. + """ + import jwt + + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _mcp_oauth_user_api_key_auth, + ) + + master_key = "test-master-key" + api_key_in_cookie = "sk-test-cookie-key" + token_cookie = jwt.encode( + { + "user_id": "user@example.com", + "key": api_key_in_cookie, + "user_role": "proxy_admin", + "login_method": "sso", + }, + master_key, + algorithm="HS256", + ) + + mock_request = MagicMock() + mock_request.headers = {} + mock_request.cookies = {"token": token_cookie} + + expected_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key=api_key_in_cookie + ) + fake_proxy_server = types.SimpleNamespace(master_key=master_key) + + with ( + patch.dict(sys.modules, {"litellm.proxy.proxy_server": fake_proxy_server}), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._user_api_key_auth_builder", + AsyncMock(return_value=expected_auth), + ) as auth_builder_mock, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._read_request_body", + AsyncMock(return_value={}), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.populate_request_with_path_params", + side_effect=lambda request_data, request: request_data, + ), + ): + result = await _mcp_oauth_user_api_key_auth(mock_request) + + assert result is expected_auth + _, call_kwargs = auth_builder_mock.call_args + assert call_kwargs["api_key"] == f"Bearer {api_key_in_cookie}" + + @pytest.mark.asyncio + async def test_mcp_oauth_user_api_key_auth_uses_authorization_header_when_present( + self, + ): + """When Authorization header is present it takes priority over the cookie.""" + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _mcp_oauth_user_api_key_auth, + ) + + expected_auth = generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + mock_request = MagicMock() + mock_request.headers = {"Authorization": "Bearer sk-header-key"} + mock_request.cookies = {} + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._user_api_key_auth_builder", + AsyncMock(return_value=expected_auth), + ) as auth_builder_mock, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._read_request_body", + AsyncMock(return_value={}), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.populate_request_with_path_params", + side_effect=lambda request_data, request: request_data, + ), + ): + result = await _mcp_oauth_user_api_key_auth(mock_request) + + assert result is expected_auth + _, call_kwargs = auth_builder_mock.call_args + assert call_kwargs["api_key"] == "Bearer sk-header-key" + @pytest.mark.asyncio async def test_mcp_authorize_proxies_to_discoverable_endpoint(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 69798744f7..2dcf6becbb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -6129,3 +6129,163 @@ class TestPKCEStateCookieBinding: # State-cookie check passed, so the function got past the early # ProxyException raise and produced an SSO result object. assert result is not None + + +@pytest.mark.asyncio +async def test_debug_sso_callback_renders_full_jwt_claims(): + """ + /sso/debug/callback should render the complete set of claims returned by the + IdP — both the raw userinfo response and the decoded access-token JWT — in + addition to the proxy-parsed OpenID fields. Bearer tokens must be stripped + even if a non-conforming IdP places them in its userinfo response. + """ + from litellm.proxy.management_endpoints.ui_sso import debug_sso_callback + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://proxy.example.com/" + mock_request.cookies = {} + mock_request.query_params = {} + + parsed_openid = CustomOpenID( + id="user_123", + email="philip@example.com", + first_name="Philip", + last_name="Schwartz", + display_name="Philip Schwartz", + provider="generic", + team_ids=["ord-engineering-high"], + user_role=None, + ) + + raw_userinfo_with_leaked_token = { + "sub": "user_123", + "email": "philip@example.com", + "team_id": "ord-engineering-high", + "team_alias": "ord-engineering-high", + "teams": ["ord-engineering-high"], + "roles": ["litellm.api.user"], + # Defense-in-depth: a non-conforming IdP could shove a bearer token + # into userinfo. The debug endpoint must strip it before rendering. + "access_token": "should-not-render", + "id_token": "should-not-render-either", + } + + access_token_payload = { + "sub": "user_123", + "scope": "openid profile email", + "groups": ["litellm-users"], + } + + async def fake_get_generic_sso_response(**kwargs): + return parsed_openid, raw_userinfo_with_leaked_token, access_token_payload + + with ( + patch.dict( + os.environ, + {"GENERIC_CLIENT_ID": "test_client_id"}, + clear=False, + ), + patch( + "litellm.proxy.management_endpoints.ui_sso.get_generic_sso_response", + side_effect=fake_get_generic_sso_response, + ), + patch("litellm.proxy.proxy_server.general_settings", {}), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.jwt_handler", MagicMock(spec=JWTHandler)), + ): + # Microsoft / Google envs may leak in from other tests — ensure only + # the generic path runs. + for var in ("MICROSOFT_CLIENT_ID", "GOOGLE_CLIENT_ID"): + os.environ.pop(var, None) + response = await debug_sso_callback(mock_request) + + body = response.body.decode() + + # The embedded JSON payload drives the rendered page. Extract and parse it + # so we can assert on shape, not on cosmetic HTML details. + marker = "const ssoData = " + start = body.index(marker) + len(marker) + end = body.index(";", start) + while body[end - 1] not in "}]": # handle ';' inside string values + end = body.index(";", end + 1) + payload = json.loads(body[start:end]) + + assert set(payload.keys()) == { + "parsed_by_proxy", + "raw_claims", + "access_token_claims", + } + + # Parsed OpenID fields are shown + assert payload["parsed_by_proxy"]["email"] == "philip@example.com" + assert payload["parsed_by_proxy"]["id"] == "user_123" + + # Raw IdP claims surface fields the OpenID model drops (the original LIT-2838 ask) + assert payload["raw_claims"]["team_id"] == "ord-engineering-high" + assert payload["raw_claims"]["team_alias"] == "ord-engineering-high" + assert payload["raw_claims"]["teams"] == ["ord-engineering-high"] + assert payload["raw_claims"]["roles"] == ["litellm.api.user"] + + # Defense-in-depth: bearer tokens must never appear in the rendered HTML + assert "access_token" not in payload["raw_claims"] + assert "id_token" not in payload["raw_claims"] + assert "should-not-render" not in body + + # Decoded access-token JWT claims are surfaced + assert payload["access_token_claims"]["groups"] == ["litellm-users"] + + +@pytest.mark.asyncio +async def test_debug_sso_callback_handles_missing_raw_response(): + """ + Microsoft and Google paths don't return a raw response or access-token + payload. The debug endpoint must still render successfully with empty + sections instead of crashing. + """ + from litellm.proxy.management_endpoints.ui_sso import debug_sso_callback + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://proxy.example.com/" + mock_request.cookies = {} + mock_request.query_params = {} + + parsed_openid = CustomOpenID( + id="user_456", + email="user@example.com", + first_name="Some", + last_name="User", + display_name="Some User", + provider="microsoft", + team_ids=[], + user_role=None, + ) + + async def fake_microsoft_callback(**kwargs): + return parsed_openid + + with ( + patch.dict( + os.environ, + {"MICROSOFT_CLIENT_ID": "test_microsoft_id"}, + clear=False, + ), + patch.object( + MicrosoftSSOHandler, + "get_microsoft_callback_response", + side_effect=fake_microsoft_callback, + ), + patch("litellm.proxy.proxy_server.general_settings", {}), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.jwt_handler", MagicMock(spec=JWTHandler)), + ): + for var in ("GENERIC_CLIENT_ID", "GOOGLE_CLIENT_ID"): + os.environ.pop(var, None) + response = await debug_sso_callback(mock_request) + + assert response.status_code == 200 + body = response.body.decode() + assert '"raw_claims": {}' in body + assert '"access_token_claims": {}' in body + assert "user@example.com" in body diff --git a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py index 987f17fe07..48e4353966 100644 --- a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py +++ b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py @@ -346,3 +346,126 @@ class TestS3LoggerAuditLogEvent: element = logger.log_queue[0] assert element.s3_object_key.startswith("audit_logs/") assert "audit-456" in element.s3_object_key + + +class TestS3AuditCallbackParamsDecoupling: + """`s3_audit_callback_params` should give the audit-log path its own + S3Logger instance, distinct from the singleton serving normal logs.""" + + @pytest.fixture(autouse=True) + def _isolate_caches_and_globals(self): + from litellm.litellm_core_utils import litellm_logging as ll_logging + from litellm.proxy.management_helpers import audit_logs as ll_audit_logs + + original_s3 = litellm.s3_callback_params + original_audit = getattr(litellm, "s3_audit_callback_params", None) + ll_audit_logs._audit_log_callback_cache.clear() + ll_logging._in_memory_loggers.clear() + yield + litellm.s3_callback_params = original_s3 + litellm.s3_audit_callback_params = original_audit + ll_audit_logs._audit_log_callback_cache.clear() + ll_logging._in_memory_loggers.clear() + + def test_opt_in_constructs_separate_instance_with_audit_config(self): + """Audit config set → audit resolver returns a fresh S3Logger pointing + at the audit bucket, distinct from the normal-log singleton.""" + from litellm.integrations.s3_v2 import S3Logger + from litellm.litellm_core_utils.litellm_logging import ( + _init_custom_logger_compatible_class, + ) + from litellm.proxy.management_helpers.audit_logs import ( + _resolve_audit_log_callback, + ) + + litellm.s3_callback_params = {"s3_bucket_name": "normal-bucket"} + litellm.s3_audit_callback_params = {"s3_bucket_name": "audit-bucket"} + + with patch("asyncio.create_task"): + audit_instance = _resolve_audit_log_callback("s3_v2") + normal_instance = _init_custom_logger_compatible_class( + logging_integration="s3_v2", + internal_usage_cache=None, + llm_router=None, + ) + + assert isinstance(audit_instance, S3Logger) + assert isinstance(normal_instance, S3Logger) + assert id(audit_instance) != id(normal_instance) + assert audit_instance.s3_bucket_name == "audit-bucket" + assert normal_instance.s3_bucket_name == "normal-bucket" + + def test_opt_out_preserves_singleton_behavior(self): + """No `s3_audit_callback_params` → audit and normal share the singleton + (existing behavior, regression guard).""" + from litellm.integrations.s3_v2 import S3Logger + from litellm.litellm_core_utils.litellm_logging import ( + _init_custom_logger_compatible_class, + ) + from litellm.proxy.management_helpers.audit_logs import ( + _resolve_audit_log_callback, + ) + + litellm.s3_callback_params = {"s3_bucket_name": "shared-bucket"} + litellm.s3_audit_callback_params = None + + with patch("asyncio.create_task"): + normal_instance = _init_custom_logger_compatible_class( + logging_integration="s3_v2", + internal_usage_cache=None, + llm_router=None, + ) + audit_instance = _resolve_audit_log_callback("s3_v2") + + assert isinstance(audit_instance, S3Logger) + assert id(audit_instance) == id(normal_instance) + assert audit_instance.s3_bucket_name == "shared-bucket" + + def test_empty_dict_opts_in(self): + """`s3_audit_callback_params = {}` is opt-in (truthy-by-presence) and + produces a separate instance with no bucket configured (env/IAM-only).""" + from litellm.integrations.s3_v2 import S3Logger + from litellm.litellm_core_utils.litellm_logging import ( + _init_custom_logger_compatible_class, + ) + from litellm.proxy.management_helpers.audit_logs import ( + _resolve_audit_log_callback, + ) + + litellm.s3_callback_params = {"s3_bucket_name": "normal-bucket"} + litellm.s3_audit_callback_params = {} + + with patch("asyncio.create_task"): + audit_instance = _resolve_audit_log_callback("s3_v2") + normal_instance = _init_custom_logger_compatible_class( + logging_integration="s3_v2", + internal_usage_cache=None, + llm_router=None, + ) + + assert id(audit_instance) != id(normal_instance) + assert audit_instance.s3_bucket_name is None + assert normal_instance.s3_bucket_name == "normal-bucket" + + def test_reset_audit_log_callback_cache_clears_audit_instance(self): + """`reset_audit_log_callback_cache()` must drop the cached audit + instance so a config reload picks up the new params.""" + from litellm.proxy.management_helpers.audit_logs import ( + _audit_log_callback_cache, + _resolve_audit_log_callback, + reset_audit_log_callback_cache, + ) + + litellm.s3_audit_callback_params = {"s3_bucket_name": "first"} + with patch("asyncio.create_task"): + first = _resolve_audit_log_callback("s3_v2") + assert first is not None and "s3_v2" in _audit_log_callback_cache + + reset_audit_log_callback_cache() + assert "s3_v2" not in _audit_log_callback_cache + + litellm.s3_audit_callback_params = {"s3_bucket_name": "second"} + second = _resolve_audit_log_callback("s3_v2") + assert second is not None + assert id(second) != id(first) + assert second.s3_bucket_name == "second" diff --git a/tests/test_litellm/proxy/middleware/test_prometheus_auth_middleware.py b/tests/test_litellm/proxy/middleware/test_prometheus_auth_middleware.py index 6cab5baee9..1d0c0f90fd 100644 --- a/tests/test_litellm/proxy/middleware/test_prometheus_auth_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_prometheus_auth_middleware.py @@ -121,6 +121,26 @@ def test_invalid_auth_metrics(app_with_middleware, monkeypatch): assert "Unauthorized access to metrics endpoint" in response.text +def test_invalid_auth_metrics_includes_optout_hint(app_with_middleware, monkeypatch): + """ + The 401 body must tell operators how to restore the previous unauthenticated + behavior, otherwise a Prometheus scraper that worked pre-upgrade just sees + "Malformed API Key" with no actionable migration path. + """ + monkeypatch.setattr(litellm, "require_auth_for_metrics_endpoint", True) + monkeypatch.setattr( + "litellm.proxy.middleware.prometheus_auth_middleware.user_api_key_auth", + fake_invalid_auth, + ) + + client = TestClient(app_with_middleware) + response = client.get("/metrics") + + assert response.status_code == 401, response.text + assert "require_auth_for_metrics_endpoint" in response.text + assert "false" in response.text + + def test_metrics_auth_uses_real_auth_when_route_is_public( app_with_middleware, monkeypatch ): diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py index 070b232066..aa0f8d6327 100644 --- a/tests/test_litellm/proxy/test_budget_reservation.py +++ b/tests/test_litellm/proxy/test_budget_reservation.py @@ -585,15 +585,24 @@ async def test_should_cap_known_estimate_to_remaining_budget( @pytest.mark.asyncio -async def test_should_reserve_remaining_budget_when_output_cap_missing( +async def test_should_clamp_reservation_to_default_when_output_cap_missing( spend_counter_state, ): + """When max_tokens is not specified, _estimate_output_tokens falls back to + DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK (16K), clamped by the model's + max_output_tokens. Reservation must be a bounded per-request amount + (mirroring parallel_request_limiter_v3's DEFAULT_MAX_TOKENS_ESTIMATE), + not the entire remaining headroom.""" + from litellm.proxy.spend_tracking.budget_reservation import ( + DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK, + ) + counter_cache, key_cache = spend_counter_state proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) valid_token = UserAPIKeyAuth( token="key-budget-uncapped", spend=0.2, - max_budget=1.0, + max_budget=10000.0, ) await key_cache.async_set_cache( key="key-budget-uncapped", @@ -602,22 +611,24 @@ async def test_should_reserve_remaining_budget_when_output_cap_missing( request_body = _request_body() request_body.pop("max_tokens") + output_cost_per_token = 1e-5 # roughly Opus 4.5/4.7 output rate + expected_cost = DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK * output_cost_per_token + with patch( "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", return_value={ "input_cost_per_token": 0.0, - "output_cost_per_token": 100.0, - "max_output_tokens": 200000, + "output_cost_per_token": output_cost_per_token, + "max_output_tokens": 200000, # well above the 16K fallback }, ): - assert ( - estimate_request_max_cost( - request_body=request_body, - route="/chat/completions", - llm_router=None, - ) - is None + estimated = estimate_request_max_cost( + request_body=request_body, + route="/chat/completions", + llm_router=None, ) + assert estimated == pytest.approx(expected_cost) + reservation = await reserve_budget_for_request( request_body=request_body, route="/chat/completions", @@ -631,47 +642,45 @@ async def test_should_reserve_remaining_budget_when_output_cap_missing( ) assert reservation is not None - assert reservation["reserved_cost"] == pytest.approx(0.8) - assert counter_cache.in_memory_cache.get_cache( - key="spend:key:key-budget-uncapped" - ) == pytest.approx(1.0) - + assert reservation["reserved_cost"] == pytest.approx(expected_cost) await release_budget_reservation(reservation) @pytest.mark.asyncio -async def test_should_shrink_uncapped_reservation_when_counter_advances( +async def test_should_clamp_reservation_to_model_ceiling_when_caller_overrequests( spend_counter_state, - monkeypatch, ): + """An adversarial caller sending max_tokens=999_999_999 must not be able + to inflate the per-request reservation up to the entire remaining team + headroom. _estimate_output_tokens clamps the explicit value at the + model's max_output_tokens — the model can only physically emit that + many tokens anyway, so anything more is both wasteful and a DoS surface.""" counter_cache, key_cache = spend_counter_state proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) valid_token = UserAPIKeyAuth( - token="key-budget-uncapped-race", - spend=0.2, - max_budget=1.0, + token="key-budget-overrequest", + spend=0.0, + max_budget=10000.0, ) + await key_cache.async_set_cache( + key="key-budget-overrequest", + value=valid_token, + ) + request_body = _request_body() - request_body.pop("max_tokens") + request_body["max_tokens"] = 999_999_999 - from litellm.proxy.spend_tracking import budget_reservation - - async def stale_counter_read(counter): - await counter_cache.async_increment_cache( - key=counter.counter_key, - value=0.3, - ) - return 0.2 - - monkeypatch.setattr( - budget_reservation, - "_get_current_counter_value", - stale_counter_read, - ) + output_cost_per_token = 1e-5 + model_ceiling = 128_000 + expected_cost = model_ceiling * output_cost_per_token with patch( - "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", - return_value=None, + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "input_cost_per_token": 0.0, + "output_cost_per_token": output_cost_per_token, + "max_output_tokens": model_ceiling, + }, ): reservation = await reserve_budget_for_request( request_body=request_body, @@ -686,66 +695,91 @@ async def test_should_shrink_uncapped_reservation_when_counter_advances( ) assert reservation is not None - assert reservation["reserved_cost"] == pytest.approx(0.7) - assert counter_cache.in_memory_cache.get_cache( - key="spend:key:key-budget-uncapped-race" - ) == pytest.approx(1.0) - + assert reservation["reserved_cost"] == pytest.approx(expected_cost) await release_budget_reservation(reservation) - assert counter_cache.in_memory_cache.get_cache( - key="spend:key:key-budget-uncapped-race" - ) == pytest.approx(0.3) - @pytest.mark.asyncio -async def test_should_shrink_uncapped_reservation_multiple_times( +async def test_should_reserve_image_generation_cost_per_image( spend_counter_state, - monkeypatch, ): + """Image-generation requests reserve `n × per-image cost` so concurrent + requests against a depleted budget cannot all bypass the admission gate. + The OpenAI ``dall-e-3`` entry exposes the per-image price as + ``input_cost_per_image`` (a naming quirk), while other providers use + ``output_cost_per_image`` — both must be honored.""" counter_cache, key_cache = spend_counter_state proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) valid_token = UserAPIKeyAuth( - token="key-budget-double-resize", - spend=0.2, - max_budget=1.0, - team_id="team-budget-double-resize", + token="key-image-gen", + spend=0.0, + max_budget=10.0, + ) + await key_cache.async_set_cache(key="key-image-gen", value=valid_token) + + request_body = {"model": "dall-e-3", "prompt": "a cat", "n": 3} + + with patch( + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "mode": "image_generation", + "input_cost_per_image": 0.04, + }, + ): + reservation = await reserve_budget_for_request( + request_body=request_body, + route="/v1/images/generations", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + assert reservation is not None + assert reservation["reserved_cost"] == pytest.approx(0.12) # 3 × $0.04 + await release_budget_reservation(reservation) + + +@pytest.mark.asyncio +async def test_should_reject_concurrent_image_request_against_depleted_budget( + spend_counter_state, +): + """Greptile P1 regression: with image-gen reservation in place, a second + concurrent image request against a budget already pinned at the cap by + the first reservation must raise BudgetExceededError instead of + silently reaching the provider.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-image-deplete", + spend=0.0, + team_id="team-image-deplete", ) team_object = LiteLLM_TeamTable( - team_id="team-budget-double-resize", - spend=0.2, - max_budget=1.0, + team_id="team-image-deplete", + max_budget=0.04, + spend=0.0, ) - request_body = _request_body() - request_body.pop("max_tokens") - - from litellm.proxy.spend_tracking import budget_reservation - - stale_spend_by_counter_key = { - "spend:key:key-budget-double-resize": 0.3, - "spend:team:team-budget-double-resize": 0.4, - } - - async def stale_counter_read(counter): - await counter_cache.async_increment_cache( - key=counter.counter_key, - value=stale_spend_by_counter_key[counter.counter_key], - ) - return 0.2 - - monkeypatch.setattr( - budget_reservation, - "_get_current_counter_value", - stale_counter_read, + await key_cache.async_set_cache( + key=f"team_id:{team_object.team_id}", + value=team_object, ) + request_body = {"model": "dall-e-3", "prompt": "a cat"} + with patch( - "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", - return_value=None, + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "mode": "image_generation", + "input_cost_per_image": 0.04, + }, ): - reservation = await reserve_budget_for_request( + first = await reserve_budget_for_request( request_body=request_body, - route="/chat/completions", + route="/v1/images/generations", llm_router=None, valid_token=valid_token, team_object=team_object, @@ -754,32 +788,167 @@ async def test_should_shrink_uncapped_reservation_multiple_times( user_api_key_cache=key_cache, proxy_logging_obj=proxy_logging_obj, ) + assert first is not None + + with pytest.raises(litellm.BudgetExceededError): + await reserve_budget_for_request( + request_body=request_body, + route="/v1/images/generations", + llm_router=None, + valid_token=valid_token, + team_object=team_object, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + await release_budget_reservation(first) + + +@pytest.mark.asyncio +async def test_should_skip_reservation_for_per_pixel_image_model( + spend_counter_state, +): + """DALL-E 2-style per-pixel pricing depends on the requested ``size``, + which we don't decode here. Fall through to read-time enforcement + rather than guess.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-image-per-pixel", + spend=0.0, + max_budget=1.0, + ) + await key_cache.async_set_cache(key="key-image-per-pixel", value=valid_token) + + request_body = {"model": "dall-e-2", "prompt": "a cat", "size": "256x256"} + + with patch( + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "mode": "image_generation", + "input_cost_per_pixel": 2.4414e-07, + "output_cost_per_pixel": 0.0, + }, + ): + reservation = await reserve_budget_for_request( + request_body=request_body, + route="/v1/images/generations", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + assert reservation is None + + +@pytest.mark.asyncio +async def test_should_use_token_pricing_for_chat_model_with_image_cost_field( + spend_counter_state, +): + """Several chat and embedding models carry ``input_cost_per_image`` / + ``output_cost_per_image`` to price multimodal vision *input*, not image + generation (e.g. gemini-3.1-pro-preview, azure/gpt-realtime-*, + amazon.titan-embed-image-v1). _estimate_image_generation_cost must gate + on ``mode`` so these models still go through the token-priced path — + otherwise a long chat reserves a fraction of a cent instead of the true + token cost.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-multimodal-chat", + spend=0.0, + max_budget=10.0, + ) + await key_cache.async_set_cache(key="key-multimodal-chat", value=valid_token) + + # Roughly the gemini-3.1-pro-preview shape: chat-mode model that + # carries an output_cost_per_image alongside token pricing. + output_cost_per_token = 1.2e-5 + request_body = { + "model": "gemini-3.1-pro-preview", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 1000, + } + expected_cost = 1000 * output_cost_per_token # token-priced path, not 1 × $0.00012 + + with patch( + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "mode": "chat", + "input_cost_per_token": 2e-6, + "output_cost_per_token": output_cost_per_token, + "output_cost_per_image": 0.00012, + "max_output_tokens": 64000, + }, + ): + reservation = await reserve_budget_for_request( + request_body=request_body, + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) assert reservation is not None - assert reservation["reserved_cost"] == pytest.approx(0.6) - assert [entry["reserved_cost"] for entry in reservation["entries"]] == [ - pytest.approx(0.6), - pytest.approx(0.6), - ] - assert [entry["applied_adjustment"] for entry in reservation["entries"]] == [ - pytest.approx(0.0), - pytest.approx(0.0), - ] - assert counter_cache.in_memory_cache.get_cache( - key="spend:key:key-budget-double-resize" - ) == pytest.approx(0.9) - assert counter_cache.in_memory_cache.get_cache( - key="spend:team:team-budget-double-resize" - ) == pytest.approx(1.0) - + # Token-priced path: reservation ≈ output_tokens × output_cost_per_token, + # plus a small input-token contribution. Must NOT collapse to the + # per-image price ($0.00012) which would indicate the image-gen branch + # incorrectly fired for this chat model. + assert reservation["reserved_cost"] == pytest.approx(expected_cost, rel=0.05) + assert reservation["reserved_cost"] > 0.001 # well above per-image price await release_budget_reservation(reservation) - assert counter_cache.in_memory_cache.get_cache( - key="spend:key:key-budget-double-resize" - ) == pytest.approx(0.3) - assert counter_cache.in_memory_cache.get_cache( - key="spend:team:team-budget-double-resize" - ) == pytest.approx(0.4) + +@pytest.mark.asyncio +async def test_should_reserve_image_edit_cost_per_image( + spend_counter_state, +): + """``image_edit`` models (Flux Kontext, Stability inpaint/outpaint, etc.) + bill per generated image just like ``image_generation`` and must get + the same atomic per-image reservation.""" + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + valid_token = UserAPIKeyAuth( + token="key-image-edit", + spend=0.0, + max_budget=10.0, + ) + await key_cache.async_set_cache(key="key-image-edit", value=valid_token) + + request_body = {"model": "stability/inpaint", "prompt": "a cat", "n": 2} + + with patch( + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "mode": "image_edit", + "output_cost_per_image": 0.05, + }, + ): + reservation = await reserve_budget_for_request( + request_body=request_body, + route="/v1/images/edits", + llm_router=None, + valid_token=valid_token, + team_object=None, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + assert reservation is not None + assert reservation["reserved_cost"] == pytest.approx(0.10) # 2 × $0.05 + await release_budget_reservation(reservation) def test_should_start_window_without_reset_at_at_duration_boundary(): @@ -1047,62 +1216,6 @@ async def test_should_release_tracked_entry_when_reservation_fails_after_increme ) == pytest.approx(0.0) -@pytest.mark.asyncio -async def test_should_not_re_read_uncapped_budget_after_reservation_fallback( - spend_counter_state, - monkeypatch, -): - _, key_cache = spend_counter_state - proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) - valid_token = UserAPIKeyAuth( - token="key-budget-uncapped-read-once", - spend=0.2, - max_budget=1.0, - ) - - from litellm.proxy.spend_tracking import budget_reservation - - current_counter_reads = [] - - async def mock_get_current_counter_value(counter): - current_counter_reads.append(counter.counter_key) - return counter.fallback_spend - - async def mock_reserve_counter(counter, reservation_cost): - return None - - monkeypatch.setattr( - budget_reservation, - "_get_current_counter_value", - mock_get_current_counter_value, - ) - monkeypatch.setattr( - budget_reservation, - "_reserve_counter", - mock_reserve_counter, - ) - - with patch( - "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost", - return_value=None, - ): - reservation = await reserve_budget_for_request( - request_body=_request_body(), - route="/chat/completions", - llm_router=None, - valid_token=valid_token, - team_object=None, - user_object=None, - prisma_client=None, - user_api_key_cache=key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - - assert reservation is not None - assert reservation["reserved_cost"] == pytest.approx(0.8) - assert current_counter_reads == ["spend:key:key-budget-uncapped-read-once"] - - @pytest.mark.asyncio async def test_should_reconcile_reserved_counter_to_actual_spend( spend_counter_state, @@ -1492,4 +1605,94 @@ async def test_should_reserve_all_budgeted_counters(spend_counter_state): counter_cache.in_memory_cache.get_cache(key="spend:team:team-budget-all") == 0.3 ) - await release_budget_reservation(reservation) + +@pytest.mark.asyncio +async def test_should_not_block_concurrent_team_request_when_first_request_lacks_max_tokens( + spend_counter_state, +): + """ + Regression test: a team-bound request with no max_tokens must not pin the + team's spend counter at max_budget for the duration of the request. + + Repro of the integration-test team being falsely budget-blocked at the + $2000 cap while DB spend is $0.144: the first request without max_tokens + used to reserve the entire remaining headroom, leaving any subsequent + request stuck behind a counter sitting at the cap until the success + callback finished reconciling. + """ + counter_cache, key_cache = spend_counter_state + proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache) + + valid_token = UserAPIKeyAuth( + token="key-team-integration-tests", + spend=0.0, + team_id="team-integration-tests", + ) + team_object = LiteLLM_TeamTable( + team_id="team-integration-tests", + max_budget=2000.0, + spend=0.144, + ) + await key_cache.async_set_cache( + key=f"team_id:{team_object.team_id}", + value=team_object, + ) + + request_body = _request_body() + request_body.pop("max_tokens") + + # Realistic Opus 4.7 output pricing — the 16K fallback × $25/M ≈ $0.40 + # reservation per request, leaving ~5000 admittable concurrent requests + # against a $2000 team budget. + with patch( + "litellm.proxy.spend_tracking.budget_reservation._get_model_cost_info", + return_value={ + "input_cost_per_token": 5e-6, + "output_cost_per_token": 2.5e-5, + "max_output_tokens": 128000, + }, + ): + first_reservation = await reserve_budget_for_request( + request_body=request_body, + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=team_object, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + # The team counter must not be pinned at max_budget while the first + # request is in flight, otherwise concurrent requests false-positive. + team_counter_after_first = ( + counter_cache.in_memory_cache.get_cache( + key=f"spend:team:{team_object.team_id}" + ) + or 0.0 + ) + assert team_counter_after_first < team_object.max_budget, ( + f"Team counter sat at {team_counter_after_first} after one uncapped " + f"reservation against a {team_object.max_budget} budget — concurrent " + "requests will be falsely blocked." + ) + + # Second request — same shape — must succeed without raising. + second_reservation = await reserve_budget_for_request( + request_body=request_body, + route="/chat/completions", + llm_router=None, + valid_token=valid_token, + team_object=team_object, + user_object=None, + prisma_client=None, + user_api_key_cache=key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + assert second_reservation is not None + + if first_reservation is not None: + await release_budget_reservation(first_reservation) + if second_reservation is not None: + await release_budget_reservation(second_reservation) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index d4b4617730..31a25916e2 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -3,8 +3,9 @@ import datetime from typing import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest -from fastapi import HTTPException, Request, status +from fastapi import HTTPException, Request, Response, status from fastapi.responses import JSONResponse, StreamingResponse import litellm @@ -26,6 +27,48 @@ from litellm.proxy.utils import ProxyLogging class TestProxyBaseLLMRequestProcessing: + @pytest.mark.asyncio + async def test_base_passthrough_process_llm_request_preserves_litellm_headers_for_non_streaming_response( + self, monkeypatch + ): + processing_obj = ProxyBaseLLMRequestProcessing(data={}) + + async def fake_base_process_llm_request(**kwargs): + passthrough_response = kwargs["fastapi_response"] + passthrough_response.headers["x-litellm-call-id"] = "test-call-id" + passthrough_response.headers["x-litellm-version"] = "test-version" + return httpx.Response( + status_code=200, + content=b'{"ok":true}', + headers={ + "content-type": "application/json", + "x-amzn-requestid": "bedrock-request-id", + }, + ) + + monkeypatch.setattr( + processing_obj, + "base_process_llm_request", + fake_base_process_llm_request, + ) + + result = await processing_obj.base_passthrough_process_llm_request( + request=MagicMock(spec=Request), + fastapi_response=Response(), + user_api_key_dict=MagicMock(spec=UserAPIKeyAuth), + proxy_logging_obj=MagicMock(spec=ProxyLogging), + general_settings={}, + proxy_config=MagicMock(spec=ProxyConfig), + select_data_generator=MagicMock(), + model="bedrock-test-model", + ) + + assert result.status_code == 200 + assert result.body == b'{"ok":true}' + assert result.headers["x-amzn-requestid"] == "bedrock-request-id" + assert result.headers["x-litellm-call-id"] == "test-call-id" + assert result.headers["x-litellm-version"] == "test-version" + @pytest.mark.asyncio async def test_common_processing_pre_call_logic_pre_call_hook_receives_litellm_call_id( self, monkeypatch diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 383f6886d1..92611431a1 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -1143,6 +1143,155 @@ async def test_add_litellm_data_to_request_preserves_user_tags_when_team_opts_in assert updated["metadata"].get("tags") == ["team-allowed"] +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_unions_caller_header_tags_with_static_key_tags(): + """Caller-supplied `x-litellm-tags` must union with static key-level + tags, not overwrite them, when `allow_client_tags=True`.""" + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request + + request_mock = MagicMock(spec=Request) + request_mock.url.path = "/v1/chat/completions" + request_mock.url = MagicMock() + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = { + "Content-Type": "application/json", + "x-litellm-tags": "tenant:1681", + } + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + + data = {"model": "gpt-3.5-turbo"} + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={ + "allow_client_tags": True, + "tags": ["team:platform", "env:prod"], + }, + team_metadata={}, + spend=0.0, + max_budget=100.0, + model_max_budget={}, + team_spend=0.0, + team_max_budget=200.0, + ) + + updated = await add_litellm_data_to_request( + data=data, + request=request_mock, + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + + final_tags = updated["metadata"].get("tags") or [] + assert "team:platform" in final_tags + assert "env:prod" in final_tags + assert "tenant:1681" in final_tags + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_unions_caller_header_tags_with_static_team_tags(): + """Same union behavior must hold for team-level static tags.""" + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request + + request_mock = MagicMock(spec=Request) + request_mock.url.path = "/v1/chat/completions" + request_mock.url = MagicMock() + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = { + "Content-Type": "application/json", + "x-litellm-tags": "tenant:42", + } + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + + data = {"model": "gpt-3.5-turbo"} + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={ + "allow_client_tags": True, + "tags": ["team:eng", "owner:platform"], + }, + spend=0.0, + max_budget=100.0, + model_max_budget={}, + team_spend=0.0, + team_max_budget=200.0, + ) + + updated = await add_litellm_data_to_request( + data=data, + request=request_mock, + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + + final_tags = updated["metadata"].get("tags") or [] + assert "team:eng" in final_tags + assert "owner:platform" in final_tags + assert "tenant:42" in final_tags + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_unions_dedups_overlapping_caller_and_static_tags(): + """A tag that appears in both the static set and the caller header + must show up exactly once in the merged list.""" + from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request + + request_mock = MagicMock(spec=Request) + request_mock.url.path = "/v1/chat/completions" + request_mock.url = MagicMock() + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = { + "Content-Type": "application/json", + "x-litellm-tags": "env:prod,tenant:7", + } + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + + data = {"model": "gpt-3.5-turbo"} + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={ + "allow_client_tags": True, + "tags": ["env:prod", "team:platform"], + }, + team_metadata={}, + spend=0.0, + max_budget=100.0, + model_max_budget={}, + team_spend=0.0, + team_max_budget=200.0, + ) + + updated = await add_litellm_data_to_request( + data=data, + request=request_mock, + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + + final_tags = updated["metadata"].get("tags") or [] + assert final_tags.count("env:prod") == 1 + assert "team:platform" in final_tags + assert "tenant:7" in final_tags + + @pytest.mark.asyncio async def test_add_litellm_data_to_request_user_spend_and_budget(): from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request diff --git a/tests/test_litellm/proxy/test_model_level_guardrails.py b/tests/test_litellm/proxy/test_model_level_guardrails.py index e83f8c67ca..3d74edd772 100644 --- a/tests/test_litellm/proxy/test_model_level_guardrails.py +++ b/tests/test_litellm/proxy/test_model_level_guardrails.py @@ -294,3 +294,134 @@ async def test_post_call_success_hook_skips_guardrail_not_on_model(): ) assert guardrail.was_called is False + + +# --------------------------------------------------------------------------- +# Integration test: async_post_call_streaming_iterator_hook with model-level guardrails +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_streaming_iterator_hook_runs_model_level_guardrail(): + """ + Model-level guardrails configured on a deployment should execute in + async_post_call_streaming_iterator_hook (streaming path) — even when + `default_on: false` and the guardrail is not in the request body. + """ + from litellm.caching.caching import DualCache + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import ProxyLogging + from litellm.types.guardrails import GuardrailEventHooks + + class TestStreamingGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test-model-guardrail", + event_hook=GuardrailEventHooks.post_call, + ) + self.was_called = False + + async def async_post_call_streaming_iterator_hook( + self, user_api_key_dict, response, request_data + ): + self.was_called = True + async for chunk in response: + yield chunk + + guardrail = TestStreamingGuardrail() + + mock_router = MagicMock() + mock_deployment = MagicMock() + mock_deployment.litellm_params.get.return_value = ["test-model-guardrail"] + mock_router.get_deployment.return_value = mock_deployment + + async def fake_response(): + yield "chunk-1" + yield "chunk-2" + + with ( + patch("litellm.callbacks", [guardrail]), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + ): + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + + request_data = { + "model": "gpt-4", + "metadata": {"model_info": {"id": "model-uuid-123"}}, + } + user_api_key_dict = UserAPIKeyAuth(api_key="test-key") + + chunks = [] + async for chunk in proxy_logging.async_post_call_streaming_iterator_hook( + response=fake_response(), + user_api_key_dict=user_api_key_dict, + request_data=request_data, + ): + chunks.append(chunk) + + assert guardrail.was_called is True + assert chunks == ["chunk-1", "chunk-2"] + + +@pytest.mark.asyncio +async def test_streaming_iterator_hook_skips_guardrail_not_on_model(): + """ + Streaming guardrails NOT configured on the model (and not in the request + body / key / team) should not execute, even after the dispatcher merge + runs. Confirms the gate stays closed for unrelated guardrails. + """ + from litellm.caching.caching import DualCache + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import ProxyLogging + from litellm.types.guardrails import GuardrailEventHooks + + class TestStreamingGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="unrelated-guardrail", + event_hook=GuardrailEventHooks.post_call, + ) + self.was_called = False + + async def async_post_call_streaming_iterator_hook( + self, user_api_key_dict, response, request_data + ): + self.was_called = True + async for chunk in response: + yield chunk + + guardrail = TestStreamingGuardrail() + + # Deployment has a DIFFERENT guardrail configured + mock_router = MagicMock() + mock_deployment = MagicMock() + mock_deployment.litellm_params.get.return_value = ["some-other-guardrail"] + mock_router.get_deployment.return_value = mock_deployment + + async def fake_response(): + yield "chunk-1" + + with ( + patch("litellm.callbacks", [guardrail]), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + ): + proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) + + request_data = { + "model": "gpt-4", + "metadata": {"model_info": {"id": "model-uuid-123"}}, + } + user_api_key_dict = UserAPIKeyAuth(api_key="test-key") + + chunks = [] + async for chunk in proxy_logging.async_post_call_streaming_iterator_hook( + response=fake_response(), + user_api_key_dict=user_api_key_dict, + request_data=request_data, + ): + chunks.append(chunk) + + assert guardrail.was_called is False + assert chunks == ["chunk-1"] diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 6fbce4a545..59b43330a2 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -2,7 +2,6 @@ import os import sys from unittest.mock import MagicMock, patch -import fastapi import pytest sys.path.insert( @@ -12,7 +11,6 @@ sys.path.insert( import builtins import types -from litellm.proxy.health_endpoints.health_app_factory import build_health_app from litellm.proxy.proxy_cli import ProxyInitializationHelpers @@ -133,6 +131,87 @@ class TestProxyInitializationHelpers: ) assert args["timeout_worker_healthcheck"] == 15 + def test_get_reload_options_no_config(self): + opts = ProxyInitializationHelpers._get_reload_options(None) + assert opts == {"reload": True} + + def test_get_reload_options_with_config_in_cwd(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + monkeypatch.chdir(tmp_path) + + opts = ProxyInitializationHelpers._get_reload_options("config.yaml") + + assert opts["reload"] is True + assert opts["reload_dirs"] == [str(tmp_path)] + assert opts["reload_includes"] == ["*.py", "config.yaml"] + + def test_get_reload_options_with_config_outside_cwd(self, tmp_path, monkeypatch): + cwd_dir = tmp_path / "work" + cwd_dir.mkdir() + elsewhere = tmp_path / "configs" + elsewhere.mkdir() + config_file = elsewhere / "proxy.yaml" + config_file.write_text("model_list: []\n") + monkeypatch.chdir(cwd_dir) + + opts = ProxyInitializationHelpers._get_reload_options(str(config_file)) + + assert opts["reload"] is True + assert opts["reload_dirs"] == [str(cwd_dir), str(elsewhere)] + assert opts["reload_includes"] == ["*.py", "proxy.yaml"] + + def test_patch_statreload_for_config_yields_yaml(self, tmp_path): + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + py_file = tmp_path / "module.py" + py_file.write_text("x = 1\n") + + applied = ProxyInitializationHelpers._patch_statreload_for_config( + str(config_file) + ) + assert applied is True + + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded_paths = {Path(p).resolve() for p in StatReload.iter_py_files(fake_self)} + + assert config_file.resolve() in yielded_paths + assert py_file.resolve() in yielded_paths + + def test_patch_statreload_for_config_is_idempotent(self, tmp_path): + from pathlib import Path + + from uvicorn.supervisors.statreload import StatReload + + if hasattr(StatReload, "_litellm_patched_config_paths"): + StatReload._litellm_patched_config_paths.clear() + + config_file = tmp_path / "config.yaml" + config_file.write_text("model_list: []\n") + py_file = tmp_path / "only.py" + py_file.write_text("x = 1\n") + + for _ in range(3): + ProxyInitializationHelpers._patch_statreload_for_config(str(config_file)) + + fake_self = types.SimpleNamespace( + config=types.SimpleNamespace(reload_dirs=[tmp_path]) + ) + yielded = list(StatReload.iter_py_files(fake_self)) + assert len(yielded) == len(set(map(str, yielded))) + yielded_paths = {Path(p).resolve() for p in yielded} + assert config_file.resolve() in yielded_paths + assert py_file.resolve() in yielded_paths + @patch("asyncio.run") @patch("builtins.print") def test_init_hypercorn_server(self, mock_print, mock_asyncio_run): @@ -690,62 +769,8 @@ class TestProxyInitializationHelpers: mock_uvicorn_run.assert_called_once() -class TestHealthAppFactory: - """Test cases for the health app factory module""" - - def test_build_health_app(self): - """Test that build_health_app creates a FastAPI app with the correct title and includes the health router""" - # Execute - health_app = build_health_app() - - # Assert - assert health_app.title == "LiteLLM Health Endpoints" - assert isinstance(health_app, fastapi.FastAPI) - - # Verify that the app has the expected health endpoints by checking route paths - # When a router is included, its routes are flattened into the main app's routes - route_paths = [] - for route in health_app.routes: - if hasattr(route, "path"): - route_paths.append(route.path) - - # Check for some expected health endpoints - expected_paths = [ - "/test", - "/health/services", - "/health", - "/health/history", - "/health/latest", - "/settings", - "/active/callbacks", - "/health/readiness", - "/health/liveliness", - "/health/liveness", - "/health/test_connection", - ] - - # At least some of the expected health endpoints should be present - found_paths = [path for path in expected_paths if path in route_paths] - assert ( - len(found_paths) > 0 - ), f"Expected to find health endpoints, but found: {route_paths}" - - # Verify that the app has routes (indicating the router was included) - assert ( - len(health_app.routes) > 0 - ), "Health app should have routes from the included router" - - def test_build_health_app_returns_different_instances(self): - """Test that build_health_app returns different FastAPI instances on each call""" - # Execute - health_app_1 = build_health_app() - health_app_2 = build_health_app() - - # Assert - assert health_app_1 is not health_app_2 - assert health_app_1.title == health_app_2.title - assert isinstance(health_app_1, fastapi.FastAPI) - assert isinstance(health_app_2, fastapi.FastAPI) +class TestRunServerDbSetup: + """Tests for run_server's prisma setup_database behavior.""" @patch("subprocess.run") @patch("atexit.register") diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 3f19db36c3..859594f7a0 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1728,6 +1728,67 @@ async def test_add_proxy_budget_to_db_only_creates_user_no_keys(): assert call_args.kwargs["query_type"] == "update_data" +@pytest.mark.asyncio +async def test_add_proxy_budget_to_db_backfills_budget_reset_at(): + """ + Test that _upsert_proxy_budget_with_reset_at_backfill issues a conditional + update_many with `WHERE budget_reset_at IS NULL` to backfill the column on + rows that pre-existed without a reset schedule. Without this, the proxy + admin row stays at NULL and reset_budget_for_litellm_users never matches + it (NULL < now() is unknown in SQL), so the global proxy budget never + resets. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + import litellm + from litellm.proxy.proxy_server import ProxyStartupEvent + + litellm.budget_duration = "30d" + litellm.max_budget = 100.0 + litellm_proxy_budget_name = "litellm-proxy-budget" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_usertable.update_many = AsyncMock(return_value={"count": 1}) + + mock_generate_key_helper = AsyncMock( + return_value={ + "user_id": litellm_proxy_budget_name, + "max_budget": 100.0, + "budget_duration": "30d", + "spend": 0, + "models": [], + } + ) + + with ( + patch( + "litellm.proxy.proxy_server.generate_key_helper_fn", + mock_generate_key_helper, + ), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + ): + await ProxyStartupEvent._upsert_proxy_budget_with_reset_at_backfill( + litellm_proxy_budget_name + ) + + # Upsert ran with the configured budget + mock_generate_key_helper.assert_called_once() + + # Backfill update_many ran with the conditional WHERE + mock_prisma.db.litellm_usertable.update_many.assert_called_once() + backfill_call = mock_prisma.db.litellm_usertable.update_many.call_args + assert backfill_call.kwargs["where"]["user_id"] == litellm_proxy_budget_name + assert backfill_call.kwargs["where"]["budget_reset_at"] is None + + # The backfilled value must be a real future datetime — anything else and + # reset_budget_for_litellm_users would still skip the row. + from datetime import datetime, timezone + + backfilled_reset_at = backfill_call.kwargs["data"]["budget_reset_at"] + assert isinstance(backfilled_reset_at, datetime) + assert backfilled_reset_at > datetime.now(timezone.utc) + + @pytest.mark.asyncio async def test_custom_ui_sso_sign_in_handler_config_loading(): """ @@ -6513,3 +6574,37 @@ async def test_get_current_spend_redis_error_falls_back_to_in_memory(): finally: ps.spend_counter_cache = orig_counter ps.prisma_client = orig_prisma + + +def test_realtime_websocket_route_aliases_registered(): + """Realtime sessions reach the proxy via three path aliases stacked on + `realtime_websocket_endpoint`. Dropping any of them silently 405s + WebSocket upgrades because the catch-all `/openai/{endpoint:path}` + HTTP passthrough only declares HTTP methods. The aliases must also be + in `LiteLLMRoutes.openai_routes` (so non-admin / team / key-scoped + auth allows them) and in `API_ROUTE_TO_CALL_TYPES` (so call-type-aware + logic such as guardrails can resolve the realtime call type).""" + from starlette.routing import WebSocketRoute + + from litellm.proxy._types import LiteLLMRoutes + from litellm.proxy.proxy_server import app + from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes + + websocket_paths = { + route.path for route in app.routes if isinstance(route, WebSocketRoute) + } + openai_routes = LiteLLMRoutes.openai_routes.value + + for expected in ("/openai/v1/realtime", "/v1/realtime", "/realtime"): + assert expected in websocket_paths, ( + f"{expected!r} missing from registered WebSocket routes; the " + f"realtime endpoint will 405 for clients hitting this path." + ) + assert expected in openai_routes, ( + f"{expected!r} missing from LiteLLMRoutes.openai_routes; " + f"non-admin / team / key-scoped users will get 403 on this path." + ) + assert API_ROUTE_TO_CALL_TYPES.get(expected) == [CallTypes.arealtime], ( + f"{expected!r} missing from API_ROUTE_TO_CALL_TYPES; call-type " + f"resolution will return None and break call-type-aware features." + ) diff --git a/tests/test_litellm/proxy/test_spend_log_cleanup.py b/tests/test_litellm/proxy/test_spend_log_cleanup.py index 4923d70a43..42bb919295 100644 --- a/tests/test_litellm/proxy/test_spend_log_cleanup.py +++ b/tests/test_litellm/proxy/test_spend_log_cleanup.py @@ -331,6 +331,172 @@ async def test_delete_old_logs_continues_on_valid_int_return(): assert total_deleted == 800 +@pytest.mark.asyncio +async def test_delete_old_logs_continues_after_single_batch_failure(monkeypatch): + """A single batch failure (e.g. DB timeout) must not abort the whole run — + subsequent batches should still execute and their counts accumulate.""" + import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + + # Zero out the failure backoff so the test doesn't take ~0.5s of real sleep. + monkeypatch.setattr( + cleanup_module, "SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.0 + ) + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + # batch 1 succeeds, batch 2 raises (one-off DB timeout), batches 3-4 succeed, + # batch 5 returns 0 → loop exits naturally. + mock_db.execute_raw = AsyncMock( + side_effect=[100, TimeoutError("simulated DB timeout"), 200, 50, 0] + ) + mock_prisma_client.db = mock_db + + cleaner = cleanup_module.SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) + total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + + # All 5 batches should have been attempted; 100 + 200 + 50 = 350 deleted. + assert mock_db.execute_raw.call_count == 5 + assert total_deleted == 350 + + +@pytest.mark.asyncio +async def test_delete_old_logs_aborts_after_consecutive_failures(monkeypatch): + """If batch failures persist for SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES + in a row (e.g. DB is down), the loop must abort instead of hot-looping.""" + import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + + # Lower the threshold so the test is fast and deterministic. + monkeypatch.setattr(cleanup_module, "SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES", 3) + monkeypatch.setattr( + cleanup_module, "SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.0 + ) + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + # Every batch raises — must abort after exactly 3 attempts, not loop forever. + mock_db.execute_raw = AsyncMock( + side_effect=ConnectionError("simulated persistent DB outage") + ) + mock_prisma_client.db = mock_db + + cleaner = cleanup_module.SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) + total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + + assert mock_db.execute_raw.call_count == 3 + assert total_deleted == 0 + + +@pytest.mark.asyncio +async def test_delete_old_logs_resets_consecutive_failures_on_success(monkeypatch): + """A success between failures must reset the consecutive-failure counter so + intermittent timeouts don't trip the abort threshold.""" + import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + + monkeypatch.setattr(cleanup_module, "SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES", 3) + monkeypatch.setattr( + cleanup_module, "SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.0 + ) + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + # Pattern: fail, fail, success (resets counter), fail, fail, success, done. + # Without reset, three of these would trip abort; with reset, they don't. + mock_db.execute_raw = AsyncMock( + side_effect=[ + TimeoutError("t1"), + TimeoutError("t2"), + 100, + TimeoutError("t3"), + TimeoutError("t4"), + 50, + 0, + ] + ) + mock_prisma_client.db = mock_db + + cleaner = cleanup_module.SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) + total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + + assert mock_db.execute_raw.call_count == 7 + assert total_deleted == 150 + + +@pytest.mark.asyncio +async def test_cleanup_uses_logger_exception_for_full_traceback(monkeypatch): + """The outer error handler must call logger.exception() (not .error(str(e))) + so Prisma/DB timeouts surface a full traceback and exception type.""" + import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + + mock_logger = MagicMock() + monkeypatch.setattr(cleanup_module, "verbose_proxy_logger", mock_logger) + + mock_prisma_client = MagicMock() + # Force the outer try/except to fire by making _should_delete_spend_logs raise. + cleaner = cleanup_module.SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + cleaner.pod_lock_manager = None + + def boom(): + raise RuntimeError("simulated prisma timeout") + + cleaner._should_delete_spend_logs = boom # type: ignore[assignment] + + await cleaner.cleanup_old_spend_logs(mock_prisma_client) + + assert mock_logger.exception.called, "expected logger.exception() to be called" + # The exception type name must appear in the formatted args so operators can + # tell *what* failed, not just "Error during cleanup:". + call_args = mock_logger.exception.call_args + formatted = call_args[0][0] % call_args[0][1:] + assert "RuntimeError" in formatted + assert "simulated prisma timeout" in formatted + + +@pytest.mark.asyncio +async def test_cleanup_releases_lock_after_persistent_batch_failures(monkeypatch): + """Even when batch deletion aborts due to consecutive failures, the pod lock + must still be released so the next scheduled run isn't permanently blocked.""" + import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module + + monkeypatch.setattr(cleanup_module, "SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES", 2) + monkeypatch.setattr( + cleanup_module, "SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.0 + ) + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + mock_db.execute_raw = AsyncMock(side_effect=TimeoutError("DB down")) + mock_prisma_client.db = mock_db + + mock_pod_lock_manager = MagicMock() + mock_pod_lock_manager.redis_cache = MagicMock() + mock_pod_lock_manager.acquire_lock = AsyncMock(return_value=True) + mock_pod_lock_manager.release_lock = AsyncMock() + + cleaner = cleanup_module.SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": "7d"} + ) + cleaner.pod_lock_manager = mock_pod_lock_manager + + await cleaner.cleanup_old_spend_logs(mock_prisma_client) + + # Cleanup didn't crash; the abort-after-failures path returned cleanly. + mock_pod_lock_manager.release_lock.assert_awaited_once() + + def test_cleanup_batch_size_env_var(monkeypatch): """Ensure batch size is configurable via environment variable""" import importlib diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index ebe175b250..e53484dd28 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -110,6 +110,25 @@ def test_wandb_model_api_pricing_entries(): assert model_info["output_cost_per_token"] == output_cost +def test_openrouter_qwen36_plus_model_info(): + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_info = litellm.model_cost.get("openrouter/qwen/qwen3.6-plus") + + assert model_info is not None + assert model_info["litellm_provider"] == "openrouter" + assert model_info["mode"] == "chat" + assert model_info["max_input_tokens"] == 1000000 + assert model_info["max_output_tokens"] == 65536 + assert model_info["input_cost_per_token"] == 3.25e-07 + assert model_info["output_cost_per_token"] == 1.95e-06 + assert model_info["supports_function_calling"] is True + assert model_info["supports_tool_choice"] is True + assert model_info["supports_reasoning"] is True + assert model_info["supports_vision"] is True + + def test_cost_calculator_with_usage(monkeypatch): os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") diff --git a/tests/test_litellm/test_xai_grok_4_3_model_metadata.py b/tests/test_litellm/test_xai_grok_4_3_model_metadata.py new file mode 100644 index 0000000000..81e7f4adf1 --- /dev/null +++ b/tests/test_litellm/test_xai_grok_4_3_model_metadata.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path + +import pytest + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + +@pytest.mark.parametrize("model", ["xai/grok-4.3", "xai/grok-4.3-latest"]) +def test_xai_grok_4_3_model_info(model): + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + info = model_cost.get(model) + assert ( + info is not None + ), f"{model} not found in model_prices_and_context_window.json" + + assert info["litellm_provider"] == "xai" + assert info["mode"] == "chat" + + assert info["input_cost_per_token"] == 1.25e-06 + assert info["output_cost_per_token"] == 2.5e-06 + assert info["cache_read_input_token_cost"] == 2e-07 + + assert info["input_cost_per_token_above_200k_tokens"] == 2.5e-06 + assert info["output_cost_per_token_above_200k_tokens"] == 5e-06 + assert info["cache_read_input_token_cost_above_200k_tokens"] == 4e-07 + + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 1000000 + assert info["max_tokens"] == 1000000 + + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_response_schema"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_web_search"] is True + + routed_model, provider, _, _ = get_llm_provider(model=model) + assert routed_model == model.split("/", 1)[1] + assert provider == "xai" + + +def test_xai_grok_4_3_backup_matches_main(): + """Ensure the bundled model cost map stays in sync with the canonical file.""" + repo_root = Path(__file__).parents[2] + main_path = repo_root / "model_prices_and_context_window.json" + backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json" + + with open(main_path) as f: + main_cost = json.load(f) + with open(backup_path) as f: + backup_cost = json.load(f) + + for model in ("xai/grok-4.3", "xai/grok-4.3-latest"): + assert backup_cost.get(model) == main_cost.get( + model + ), f"{model} differs between main and backup model cost maps" diff --git a/tests/test_openai_endpoints.py b/tests/test_openai_endpoints.py index 024d05e103..8a3f9361ba 100644 --- a/tests/test_openai_endpoints.py +++ b/tests/test_openai_endpoints.py @@ -303,7 +303,7 @@ async def test_chat_completion(): api_key=key_gen["key"], api_version="2024-02-15-preview", ) - with pytest.raises(openai.AuthenticationError) as e: + with pytest.raises(openai.PermissionDeniedError) as e: response = await azure_client.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": "Hello!"}], diff --git a/tests/test_users.py b/tests/test_users.py index 05253a19aa..57fbb0483e 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -302,14 +302,14 @@ async def test_user_model_access(): model="good-model", ) - with pytest.raises(openai.AuthenticationError): + with pytest.raises(openai.PermissionDeniedError): await chat_completion( session=session, key=key, model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0", ) - with pytest.raises(openai.AuthenticationError): + with pytest.raises(openai.PermissionDeniedError): await chat_completion( session=session, key=key, diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 560baedcde..b33b2a69be 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -231,23 +231,23 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", - "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -255,7 +255,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -301,9 +301,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -317,9 +317,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -350,9 +350,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -366,13 +366,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -386,17 +386,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -410,21 +410,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -438,16 +438,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", - "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -459,12 +459,20 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -478,25 +486,25 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -504,9 +512,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -527,9 +535,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -544,9 +552,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -561,9 +569,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -578,9 +586,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -595,9 +603,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -612,9 +620,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -629,9 +637,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -646,9 +654,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -663,9 +671,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -680,9 +688,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -697,9 +705,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -714,9 +722,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -731,9 +739,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -748,9 +756,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -765,9 +773,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -782,9 +790,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -799,9 +807,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -816,9 +824,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -833,9 +841,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -850,9 +858,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -867,9 +875,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -884,9 +892,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -901,9 +909,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -918,9 +926,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -935,9 +943,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -952,9 +960,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -1011,15 +1019,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1052,20 +1060,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1113,9 +1121,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.10.0.tgz", - "integrity": "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { @@ -1131,22 +1139,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { @@ -1178,9 +1186,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@headlessui/react": { @@ -1218,12 +1226,12 @@ } }, "node_modules/@headlessui/react/node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.5" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1252,29 +1260,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1304,9 +1326,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -1769,10 +1791,37 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1815,16 +1864,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@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/@next/env": { @@ -2029,9 +2084,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.17.0.tgz", - "integrity": "sha512-kVnY21v0GyZ/+LG6EIO48wK3mE79BUuakHUYLIqobO/Qqq4mJsjuYXMSn3JtLcKZpN1HDVit4UHpGJHef1lrlw==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", + "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", "cpu": [ "arm" ], @@ -2043,9 +2098,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.17.0.tgz", - "integrity": "sha512-Pf8e3XcsK9a8RHInoAtEcrwf2vp7V9bSturyUUYxw9syW6E7cGi7z9+6ADXxm+8KAevVfLA7pfBg8NXTvz/HOw==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", + "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", "cpu": [ "arm64" ], @@ -2057,9 +2112,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.17.0.tgz", - "integrity": "sha512-lVSgKt3biecofXVr8e1hnfX0IYMd4A6VCxmvOmHsFt5Zbmt0lkO4S2ap2bvQwYDYh5ghUNamC7M2L8K6vishhQ==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", + "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", "cpu": [ "arm64" ], @@ -2071,9 +2126,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.17.0.tgz", - "integrity": "sha512-+/raxVJE1bo7R4fA9Yp0wm3slaCOofTEeUzM01YqEGcRDLHB92WRGjRhagMG2wGlvqFuSiTp81DwSbBVo/g6AQ==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", + "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", "cpu": [ "x64" ], @@ -2085,9 +2140,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.17.0.tgz", - "integrity": "sha512-x9Ks56n+n8h0TLhzA6sJXa2tGh3uvMGpBppg6PWf8oF0s5S/3p/J6k1vJJ9lIUtTmenfCQEGKnFokpRP4fLTLg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", + "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", "cpu": [ "x64" ], @@ -2099,9 +2154,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.17.0.tgz", - "integrity": "sha512-Wf3w07Ow9kXVJrS0zmsaFHKOGhXKXE8j1tNyy+qIYDsQWQ4UQZVx5SjlDTcqBnFerlp3Z3Is0RjmVzgoLG3qkA==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", + "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", "cpu": [ "arm" ], @@ -2113,9 +2168,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.17.0.tgz", - "integrity": "sha512-N0OKA1al1gQ5Gm7Fui1RWlXaHRNZlwMoBLn3TVtSXX+WbnlZoVyDqqOqFL8+pVEHhhxEA2LR8kmM0JO6FAk6dg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", + "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", "cpu": [ "arm" ], @@ -2127,9 +2182,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.17.0.tgz", - "integrity": "sha512-wdcQ7Niad9JpjZIGEeqKJnTvczVunqlZ/C06QzR5zOQNeLVRScQ9S5IesKWUAPsJQDizV+teQX53nTK+Z5Iy+g==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", + "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", "cpu": [ "arm64" ], @@ -2141,9 +2196,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.17.0.tgz", - "integrity": "sha512-65B2/t39HQN5AEhkLsC+9yBD1iRUkKOIhfmJEJ7g6wQ9kylra7JRmNmALFjbsj0VJsoSQkpM8K07kUZuNJ9Kxw==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", + "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", "cpu": [ "arm64" ], @@ -2155,9 +2210,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.17.0.tgz", - "integrity": "sha512-kExgm3TLK21dNMmcH+xiYGbc6BUWvT03PUZ2aYn8mUzGPeeORklBhg3iYcaBI3ZQHB25412X1Z6LLYNjt4aIaA==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", + "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", "cpu": [ "ppc64" ], @@ -2169,9 +2224,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.17.0.tgz", - "integrity": "sha512-1utUJC714/ydykZQE8c7QhpEyM4SaslMfRXxN9G61KYazr6ndt85LaubK3EZCSD50vVEfF4PVwFysCSO7LN9uA==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", + "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", "cpu": [ "riscv64" ], @@ -2183,9 +2238,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.17.0.tgz", - "integrity": "sha512-mayiYOl3LMmtO2CLn4I5lhanfxEo0LAqlT/EQyFbu1ZN3RS+Xa7Q3JEM0wBpVIyfO/pqFrjvC5LXw/mHNDEL7A==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", + "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", "cpu": [ "riscv64" ], @@ -2197,9 +2252,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.17.0.tgz", - "integrity": "sha512-Ow/yI+CrUHxIIhn/Y1sP/xoRKbCC3x9O1giKr3G/pjMe+TCJ5ZmfqVWU61JWwh1naC8X5Xa7uyLnbzyYqPsHfg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", + "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", "cpu": [ "s390x" ], @@ -2211,9 +2266,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.17.0.tgz", - "integrity": "sha512-Z4J7XlPMQOLPANyu6y3B3V417Md4LKH5bV6bhqgaG99qLHmU5LV2k9ErV14fSqoRc/GU/qOpqMdotxiJqN/YWg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", + "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", "cpu": [ "x64" ], @@ -2225,9 +2280,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.17.0.tgz", - "integrity": "sha512-0effK+8lhzXsgsh0Ny2ngdnTPF30v6QQzVFApJ1Ctk315YgpGkghkelvrLYYgtgeFJFrzwmOJ2nDvCrUFKsS2Q==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", + "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", "cpu": [ "x64" ], @@ -2239,9 +2294,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.17.0.tgz", - "integrity": "sha512-kFB48dRUW6RovAICZaxHKdtZe+e94fSTNA2OedXokzMctoU54NPZcv0vUX5PMqyikLIKJBIlW7laQidnAzNrDA==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", + "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", "cpu": [ "arm64" ], @@ -2253,9 +2308,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.17.0.tgz", - "integrity": "sha512-a3elKSBLPT0OoRPxTkCIIc+4xnOELolEBkPyvdj01a6PSdSmyJ1NExWjWLaXnT6wBMblvKde5RmSwEi3j+jZpg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", + "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", "cpu": [ "wasm32" ], @@ -2269,27 +2324,10 @@ "node": ">=14.0.0" } }, - "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.17.0.tgz", - "integrity": "sha512-4eszUsSDb9YVx0RtYkPWkxxtSZIOgfeiX//nG5cwRRArg178w4RCqEF1kbKPud9HPrp1rXh7gE4x911OhvTnPg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", + "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", "cpu": [ "arm64" ], @@ -2301,9 +2339,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.17.0.tgz", - "integrity": "sha512-t946xTXMmR7yGH0KAe9rB055/X4EPIu93JUvjchl2cizR5QbuwkUV7vLS2BS6x6sfvDoQb6rWYnV1HCci6tBSg==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", + "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", "cpu": [ "ia32" ], @@ -2315,9 +2353,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.17.0.tgz", - "integrity": "sha512-pX6s2kMXLQg+hlqKk5UqOW09iLLxnTkvn8ohpYp2Mhsm2yzDPCx9dyOHiB/CQixLzTkLQgWWJykN4Z3UfRKW4Q==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", + "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", "cpu": [ "x64" ], @@ -2394,9 +2432,9 @@ } }, "node_modules/@rc-component/mini-decimal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", - "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.0" @@ -2499,16 +2537,13 @@ } }, "node_modules/@react-aria/focus": { - "version": "3.21.3", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.3.tgz", - "integrity": "sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==", + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.26.0", - "@react-aria/utils": "^3.32.0", - "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" + "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", @@ -2516,80 +2551,24 @@ } }, "node_modules/@react-aria/interactions": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.26.0.tgz", - "integrity": "sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", "license": "Apache-2.0", "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.32.0", - "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.32.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.32.0.tgz", - "integrity": "sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.32.1", + "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" + "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-stately/flags": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", - "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", - "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-types/shared": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", - "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -2605,9 +2584,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -2619,9 +2598,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -2633,9 +2612,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -2647,9 +2626,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -2661,9 +2640,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -2675,9 +2654,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -2689,9 +2668,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -2703,9 +2682,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], @@ -2717,9 +2696,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -2731,9 +2710,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], @@ -2745,9 +2724,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], @@ -2759,9 +2738,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], @@ -2773,9 +2752,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], @@ -2787,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], @@ -2801,9 +2780,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], @@ -2815,9 +2794,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], @@ -2829,9 +2808,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], @@ -2843,9 +2822,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], @@ -2857,9 +2836,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], @@ -2871,9 +2850,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -2885,9 +2864,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -2899,9 +2878,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -2913,9 +2892,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -2927,9 +2906,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -2941,9 +2920,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -2962,16 +2941,16 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", - "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", "dev": true, "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", - "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -3070,12 +3049,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.18", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", - "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.18" + "@tanstack/virtual-core": "3.14.0" }, "funding": { "type": "github", @@ -3100,9 +3079,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.18", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", - "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", "license": "MIT", "funding": { "type": "github", @@ -3228,9 +3207,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -3330,9 +3309,9 @@ "license": "MIT" }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { "@types/ms": "*" @@ -3500,20 +3479,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3523,9 +3502,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3539,16 +3518,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -3559,19 +3538,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -3582,18 +3561,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3604,9 +3583,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -3617,21 +3596,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3641,14 +3620,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -3660,21 +3639,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3684,20 +3663,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3707,19 +3686,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3729,6 +3708,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3962,6 +3954,19 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", @@ -4188,9 +4193,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -4233,9 +4238,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4584,9 +4589,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", - "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4672,9 +4677,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4712,12 +4717,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -4768,9 +4776,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -4788,11 +4796,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4812,15 +4820,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -4880,9 +4888,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "funding": [ { "type": "opencollective", @@ -5132,14 +5140,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -5635,9 +5643,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "dev": true, "license": "ISC" }, @@ -5649,22 +5657,22 @@ "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5749,16 +5757,16 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -5770,7 +5778,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5842,9 +5850,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5855,32 +5863,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -6011,15 +6019,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -6235,24 +6243,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6812,9 +6802,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", - "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6976,9 +6966,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7392,12 +7382,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -7821,12 +7811,13 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -8051,16 +8042,6 @@ "node": ">= 6" } }, - "node_modules/knip/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/knip/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -8075,9 +8056,9 @@ } }, "node_modules/knip/node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { @@ -8209,9 +8190,9 @@ } }, "node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8323,9 +8304,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -8577,9 +8558,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, @@ -9248,11 +9229,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -9294,9 +9275,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -9416,6 +9397,35 @@ "node": ">=10.5.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9459,9 +9469,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -9687,35 +9697,35 @@ } }, "node_modules/oxc-resolver": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.17.0.tgz", - "integrity": "sha512-R5P2Tw6th+nQJdNcZGfuppBS/sM0x1EukqYffmlfX2xXLgLGCCPwu4ruEr9Sx29mrpkHgITc130Qps2JR90NdQ==", + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", + "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.17.0", - "@oxc-resolver/binding-android-arm64": "11.17.0", - "@oxc-resolver/binding-darwin-arm64": "11.17.0", - "@oxc-resolver/binding-darwin-x64": "11.17.0", - "@oxc-resolver/binding-freebsd-x64": "11.17.0", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.17.0", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.17.0", - "@oxc-resolver/binding-linux-arm64-gnu": "11.17.0", - "@oxc-resolver/binding-linux-arm64-musl": "11.17.0", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.17.0", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.17.0", - "@oxc-resolver/binding-linux-riscv64-musl": "11.17.0", - "@oxc-resolver/binding-linux-s390x-gnu": "11.17.0", - "@oxc-resolver/binding-linux-x64-gnu": "11.17.0", - "@oxc-resolver/binding-linux-x64-musl": "11.17.0", - "@oxc-resolver/binding-openharmony-arm64": "11.17.0", - "@oxc-resolver/binding-wasm32-wasi": "11.17.0", - "@oxc-resolver/binding-win32-arm64-msvc": "11.17.0", - "@oxc-resolver/binding-win32-ia32-msvc": "11.17.0", - "@oxc-resolver/binding-win32-x64-msvc": "11.17.0" + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "node_modules/p-limit": { @@ -9795,13 +9805,13 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -9834,9 +9844,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9844,7 +9854,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9990,6 +10000,27 @@ "postcss": "^8.0.0" } }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/postcss-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", @@ -10845,6 +10876,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/react-copy-to-clipboard": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz", @@ -10859,9 +10911,9 @@ } }, "node_modules/react-day-picker": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", - "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz", + "integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==", "license": "MIT", "funding": { "type": "individual", @@ -10869,7 +10921,7 @@ }, "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/react-dom": { @@ -10946,6 +10998,23 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.6", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", @@ -11308,12 +11377,16 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -11358,9 +11431,9 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", "dependencies": { @@ -11374,31 +11447,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -11426,15 +11499,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -11512,9 +11585,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, "license": "ISC", "bin": { @@ -11662,14 +11735,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -12037,9 +12110,9 @@ } }, "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, "node_modules/sucrase": { @@ -12177,16 +12250,46 @@ "node": ">= 6" } }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", - "minimatch": "^9.0.4" + "minimatch": "^10.2.2" }, "engines": { "node": ">=18" @@ -12243,13 +12346,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12289,22 +12392,22 @@ } }, "node_modules/tldts": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.21.tgz", - "integrity": "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==", + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.21" + "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.21.tgz", - "integrity": "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==", + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", "dev": true, "license": "MIT" }, @@ -12337,9 +12440,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12389,9 +12492,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -12719,6 +12822,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13233,6 +13345,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "extraneous": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx index c91a6e85cd..16c1c6efec 100644 --- a/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/add_guardrail_form.tsx @@ -5,6 +5,7 @@ import { createGuardrailCall, getGuardrailProviderSpecificParams, getGuardrailUI import ContentFilterConfiguration from "./content_filter/ContentFilterConfiguration"; import { choiceToSkipSystemForCreate, + choiceToSkipToolForCreate, getGuardrailProviders, guardrail_provider_map, guardrailLogoMap, @@ -188,6 +189,7 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a mode: preset.mode, default_on: preset.defaultOn, skip_system_message_choice: "inherit", + skip_tool_message_choice: "inherit", }; if (preset.provider === "BlockCodeExecution") { baseValues.confidence_threshold = 0.5; @@ -433,6 +435,11 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a guardrailData.litellm_params.skip_system_message_in_guardrail = skipForCreate; } + const skipToolForCreate = choiceToSkipToolForCreate(values.skip_tool_message_choice); + if (skipToolForCreate !== undefined) { + guardrailData.litellm_params.skip_tool_message_in_guardrail = skipToolForCreate; + } + // For Presidio PII, add the entity and action configurations if (values.provider === "PresidioPII" && selectedEntities.length > 0) { const piiEntitiesConfig: { [key: string]: string } = {}; @@ -804,6 +811,18 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a + + + + {/* Use the GuardrailProviderFields component to render provider-specific fields */} {!isToolPermissionProvider && !shouldRenderContentFilterConfigSettings(selectedProvider) && !shouldRenderLLMJudgeFields(selectedProvider) && ( = ({ visible, onClose, a mode: "pre_call", default_on: false, skip_system_message_choice: "inherit", + skip_tool_message_choice: "inherit", }} > {stepConfigs.map((step, index) => { diff --git a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx index ad823df53f..8ba9b0b312 100644 --- a/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/edit_guardrail_form.tsx @@ -6,6 +6,7 @@ import { guardrailLogoMap, getGuardrailProviders, type SkipSystemMessageChoice, + type SkipToolMessageChoice, } from "./guardrail_info_helpers"; import { getGuardrailUISettings, getGlobalLitellmHeaderName } from "../networking"; import PiiConfiguration from "./pii_configuration"; @@ -29,6 +30,7 @@ interface EditGuardrailFormProps { default_on: boolean; pii_entities_config?: { [key: string]: string }; skip_system_message_choice?: SkipSystemMessageChoice; + skip_tool_message_choice?: SkipToolMessageChoice; [key: string]: any; }; } @@ -138,6 +140,15 @@ const EditGuardrailForm: React.FC = ({ delete litellm_params.skip_system_message_in_guardrail; } + const skipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined; + if (skipToolChoice === "yes") { + litellm_params.skip_tool_message_in_guardrail = true; + } else if (skipToolChoice === "no") { + litellm_params.skip_tool_message_in_guardrail = false; + } else { + delete litellm_params.skip_tool_message_in_guardrail; + } + let guardrail_info: any = {}; // For Presidio PII, add the entity and action configurations @@ -432,6 +443,18 @@ const EditGuardrailForm: React.FC = ({ + + + + {renderProviderSpecificFields()}
diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx index 60400443d5..53aebcff0d 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info.tsx @@ -29,7 +29,9 @@ import { getGuardrailLogoAndName, guardrail_provider_map, skipSystemMessageToChoice, + skipToolMessageToChoice, type SkipSystemMessageChoice, + type SkipToolMessageChoice, } from "./guardrail_info_helpers"; import GuardrailOptionalParams from "./guardrail_optional_params"; import GuardrailProviderFields from "./guardrail_provider_fields"; @@ -214,12 +216,16 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, if (guardrailData && form) { const lp = { ...(guardrailData.litellm_params || {}) }; delete lp.skip_system_message_in_guardrail; + delete lp.skip_tool_message_in_guardrail; form.setFieldsValue({ guardrail_name: guardrailData.guardrail_name, ...lp, skip_system_message_choice: skipSystemMessageToChoice( guardrailData.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", // Include any optional_params if they exist ...(guardrailData.litellm_params?.optional_params && { @@ -302,6 +308,20 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, } } + const prevSkipToolChoice = skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ); + const nextSkipToolChoice = values.skip_tool_message_choice as SkipToolMessageChoice | undefined; + if (nextSkipToolChoice !== undefined && nextSkipToolChoice !== prevSkipToolChoice) { + if (nextSkipToolChoice === "inherit") { + updateData.litellm_params.skip_tool_message_in_guardrail = null; + } else if (nextSkipToolChoice === "yes") { + updateData.litellm_params.skip_tool_message_in_guardrail = true; + } else { + updateData.litellm_params.skip_tool_message_in_guardrail = false; + } + } + // Only include guardrail_info if it has changed const originalGuardrailInfo = guardrailData.guardrail_info; const newGuardrailInfo = values.guardrail_info ? JSON.parse(values.guardrail_info) : undefined; @@ -674,11 +694,15 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, ...(() => { const lp = { ...(guardrailData.litellm_params || {}) }; delete lp.skip_system_message_in_guardrail; + delete lp.skip_tool_message_in_guardrail; return lp; })(), skip_system_message_choice: skipSystemMessageToChoice( guardrailData.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + guardrailData.litellm_params?.skip_tool_message_in_guardrail, + ), guardrail_info: guardrailData.guardrail_info ? JSON.stringify(guardrailData.guardrail_info, null, 2) : "", @@ -716,6 +740,18 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, + + + + {guardrailData.litellm_params?.guardrail === "presidio" && ( <> PII Protection diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx index dfda86c1e4..1fc62f94cf 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.test.tsx @@ -12,6 +12,8 @@ import { GuardrailProviders, skipSystemMessageToChoice, choiceToSkipSystemForCreate, + skipToolMessageToChoice, + choiceToSkipToolForCreate, } from "./guardrail_info_helpers"; describe("guardrail_info_helpers", () => { @@ -215,4 +217,18 @@ describe("guardrail_info_helpers", () => { expect(choiceToSkipSystemForCreate("no")).toBe(false); }); }); + + describe("skipToolMessageToChoice / choiceToSkipToolForCreate", () => { + it("maps API values to form choices and back for create", () => { + expect(skipToolMessageToChoice(undefined)).toBe("inherit"); + expect(skipToolMessageToChoice(null)).toBe("inherit"); + expect(skipToolMessageToChoice(true)).toBe("yes"); + expect(skipToolMessageToChoice(false)).toBe("no"); + + expect(choiceToSkipToolForCreate("inherit")).toBeUndefined(); + expect(choiceToSkipToolForCreate(undefined)).toBeUndefined(); + expect(choiceToSkipToolForCreate("yes")).toBe(true); + expect(choiceToSkipToolForCreate("no")).toBe(false); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx index ac4b787e96..54b16b8176 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_info_helpers.tsx @@ -179,3 +179,19 @@ export function choiceToSkipSystemForCreate(choice: SkipSystemMessageChoice | un if (choice === "no") return false; return undefined; } + +/** Tri-state UI value for `litellm_params.skip_tool_message_in_guardrail` (inherit = use global). */ +export type SkipToolMessageChoice = "inherit" | "yes" | "no"; + +export function skipToolMessageToChoice(v: boolean | null | undefined): SkipToolMessageChoice { + if (v === true) return "yes"; + if (v === false) return "no"; + return "inherit"; +} + +/** Create flow: omit key when inheriting global default. */ +export function choiceToSkipToolForCreate(choice: SkipToolMessageChoice | undefined): boolean | undefined { + if (choice === "yes") return true; + if (choice === "no") return false; + return undefined; +} diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx index 5bb2da78fa..ecf6ce48fd 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx @@ -11,7 +11,12 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { getGuardrailLogoAndName, guardrail_provider_map, skipSystemMessageToChoice } from "./guardrail_info_helpers"; +import { + getGuardrailLogoAndName, + guardrail_provider_map, + skipSystemMessageToChoice, + skipToolMessageToChoice, +} from "./guardrail_info_helpers"; import EditGuardrailForm from "./edit_guardrail_form"; import { Guardrail, GuardrailDefinitionLocation } from "./types"; @@ -304,6 +309,9 @@ const GuardrailTable: React.FC = ({ skip_system_message_choice: skipSystemMessageToChoice( selectedGuardrail.litellm_params?.skip_system_message_in_guardrail, ), + skip_tool_message_choice: skipToolMessageToChoice( + selectedGuardrail.litellm_params?.skip_tool_message_in_guardrail, + ), ...selectedGuardrail.guardrail_info, }} /> diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 3c107fa586..05aa2d5162 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -404,3 +404,52 @@ describe("individualModelHealthCheckCall", () => { expect(parsed.searchParams.get("model_id")).toBe("id/with/slashes"); }); }); + +describe("teamInfoCall", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("should URL-encode team_id query param to handle special characters safely", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ team_id: "team with spaces & special?chars" }), + } as any); + global.fetch = mockFetch as any; + + const teamID = "team with spaces & special?chars"; + await Networking.teamInfoCall("token", teamID); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url] = mockFetch.mock.calls[0]; + const urlStr = typeof url === "string" ? url : (url as Request).url; + const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url); + + expect(urlStr).toContain("/team/info"); + // Encoded value is present in the raw URL string (verifies encodeURIComponent was used) + expect(urlStr).toContain(`team_id=${encodeURIComponent(teamID)}`); + // Round-trip parse returns the original team_id + expect(parsed.searchParams.get("team_id")).toBe(teamID); + }); + + it("should not append team_id when teamID is null", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + } as any); + global.fetch = mockFetch as any; + + await Networking.teamInfoCall("token", null); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url] = mockFetch.mock.calls[0]; + const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url); + expect(parsed.searchParams.has("team_id")).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 44208904a7..8bfe7c9869 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1386,7 +1386,7 @@ export const teamInfoCall = async (accessToken: string, teamID: string | null) = try { let url = proxyBaseUrl ? `${proxyBaseUrl}/team/info` : `/team/info`; if (teamID) { - url = `${url}?team_id=${teamID}`; + url = `${url}?team_id=${encodeURIComponent(teamID)}`; } console.log("in teamInfoCall"); const response = await fetch(url, { diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 3ad59cb369..bd139df483 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -155,6 +155,15 @@ vi.mock("antd", () => { const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) => React.createElement("button", { ...props, type: htmlType ?? props.type }, children); + const Typography = ({ children, ...props }: { children?: any }) => + React.createElement("div", props, children); + Typography.Text = ({ children, ...props }: { children?: any }) => + React.createElement("span", props, children); + Typography.Paragraph = ({ children, ...props }: { children?: any }) => + React.createElement("p", props, children); + Typography.Title = ({ children, ...props }: { children?: any }) => + React.createElement("h1", props, children); + return { Button, Form, @@ -171,6 +180,7 @@ vi.mock("antd", () => { Switch, Tag, Tooltip, + Typography, }; }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 2905090750..7d3f077daf 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -9,7 +9,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd"; +import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd"; import debounce from "lodash/debounce"; import React, { useCallback, useEffect, useState } from "react"; import { rolesWithWriteAccess } from "../../utils/roles"; @@ -979,28 +979,28 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp } }} > - + diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 2e4d0d97e4..1886075a9d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -158,8 +158,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -176,8 +176,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -194,8 +194,8 @@ describe("KeyEditView", () => { const { getByLabelText } = renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -219,7 +219,7 @@ describe("KeyEditView", () => { { }} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -241,8 +241,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -259,8 +259,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -277,8 +277,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -295,8 +295,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -314,7 +314,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -344,8 +344,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -367,8 +367,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={""} userID={""} userRole={""} @@ -385,8 +385,8 @@ describe("KeyEditView", () => { renderWithProviders( { }} - onSubmit={async () => { }} + onCancel={() => {}} + onSubmit={async () => {}} accessToken={"test-token"} userID={""} userRole={""} @@ -404,7 +404,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -434,10 +434,14 @@ describe("KeyEditView", () => { it("should handle empty allowed routes string on submit", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataWithRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: ["llm_api_routes"], + }; renderWithProviders( { }} + keyData={keyDataWithRoutes} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -463,6 +467,101 @@ describe("KeyEditView", () => { }); }); + it("should omit allowed_routes from submit when value is unchanged", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const aiApisKeyData = { + ...MOCK_KEY_DATA, + allowed_routes: ["llm_api_routes"], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); + + it("should omit allowed_routes from submit when keyData.allowed_routes is null and form is untouched", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataNullRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: null as unknown as string[], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); + + it("should omit allowed_routes from submit when server returned routes in a different order", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataReordered = { + ...MOCK_KEY_DATA, + allowed_routes: ["beta_routes", "alpha_routes"], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect("allowed_routes" in callArgs).toBe(false); + }); + }); it("should pass access_group_ids to onSubmit when saving key with access groups", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); @@ -554,7 +653,7 @@ describe("KeyEditView", () => { renderWithProviders( { }} + onCancel={() => {}} onSubmit={onSubmitMock} accessToken={"test-token"} userID={"test-user"} @@ -576,10 +675,13 @@ describe("KeyEditView", () => { }); // Wait for the cancel button to actually be disabled (state update may take a moment) - await waitFor(() => { - const cancelButton = screen.getByRole("button", { name: /cancel/i }); - expect(cancelButton).toBeDisabled(); - }, { timeout: 3000 }); + await waitFor( + () => { + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + }, + { timeout: 3000 }, + ); // Clean up: resolve the promise to allow the form to complete if (resolveSubmit) { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 9b38d930a5..d5e410029a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -78,7 +78,6 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin return "default"; }; - export function KeyEditView({ keyData, onCancel, @@ -106,7 +105,7 @@ export function KeyEditView({ const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); const [budgetLimits, setBudgetLimits] = useState( - Array.isArray(keyData.budget_limits) ? keyData.budget_limits : [] + Array.isArray(keyData.budget_limits) ? keyData.budget_limits : [], ); const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects } = useProjects(); @@ -116,9 +115,7 @@ export function KeyEditView({ const projectDisplay = (() => { if (!keyData.project_id) return null; const project = projects?.find((p) => p.project_id === keyData.project_id); - return project?.project_alias - ? `${project.project_alias} (${keyData.project_id})` - : keyData.project_id; + return project?.project_alias ? `${project.project_alias} (${keyData.project_id})` : keyData.project_id; })(); useEffect(() => { @@ -198,9 +195,10 @@ export function KeyEditView({ access_group_ids: keyData.access_group_ids || [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 - ? keyData.allowed_routes.join(", ") - : "", + allowed_routes: + Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }; useEffect(() => { @@ -226,9 +224,10 @@ export function KeyEditView({ access_group_ids: keyData.access_group_ids || [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 - ? keyData.allowed_routes.join(", ") - : "", + allowed_routes: + Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }); }, [keyData, form]); @@ -275,12 +274,25 @@ export function KeyEditView({ } // If it's already an array (shouldn't happen, but handle it), keep as is + // Backend rejects non-empty allowed_routes from non-admins, so re-sending + // an unchanged value 403s a team admin. Set compare tolerates reorder. + const originalRoutesSet = new Set(Array.isArray(keyData.allowed_routes) ? keyData.allowed_routes : []); + const submittedRoutesSet = new Set(Array.isArray(values.allowed_routes) ? values.allowed_routes : []); + const allowedRoutesUnchanged = + originalRoutesSet.size === submittedRoutesSet.size && + [...submittedRoutesSet].every((r) => originalRoutesSet.has(r)); + if (allowedRoutesUnchanged) { + delete values.allowed_routes; + } + if (neverExpire) { values.duration = null; } // Include multi-window budget limits (filter out incomplete entries) - const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined); + const validWindows = budgetLimits.filter( + (w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined, + ); values.budget_limits = validWindows.length > 0 ? validWindows : undefined; await onSubmit(values); @@ -305,9 +317,13 @@ export function KeyEditView({ {({ getFieldValue, setFieldValue }) => { const allowedRoutesValue = getFieldValue("allowed_routes") || ""; // Convert string to array for checking - const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" - ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) - : []; + const allowedRoutes = + typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue + .split(",") + .map((r: string) => r.trim()) + .filter((r: string) => r.length > 0) + : []; const isDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes"); const models = getFieldValue("models") || []; @@ -348,9 +364,13 @@ export function KeyEditView({ {({ getFieldValue, setFieldValue }) => { const allowedRoutesValue = getFieldValue("allowed_routes") || ""; // Convert string to array for getKeyTypeFromRoutes - const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" - ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) - : []; + const allowedRoutes = + typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue + .split(",") + .map((r: string) => r.trim()) + .filter((r: string) => r.length > 0) + : []; const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes); return ( @@ -415,9 +435,7 @@ export function KeyEditView({ } name="allowed_routes" > - + @@ -442,10 +460,7 @@ export function KeyEditView({ } > - + @@ -579,7 +594,7 @@ export function KeyEditView({ !premiumUser ? "Premium feature - Upgrade to set allowed pass through routes by key" : Array.isArray(keyData.metadata?.allowed_passthrough_routes) && - keyData.metadata.allowed_passthrough_routes.length > 0 + keyData.metadata.allowed_passthrough_routes.length > 0 ? `Current: ${keyData.metadata.allowed_passthrough_routes.join(", ")}` : "Select or enter allowed pass through routes" } @@ -690,14 +705,13 @@ export function KeyEditView({ return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false; }} > - {(selectedOrganizationId - ? teams?.filter((t) => t.organization_id === selectedOrganizationId) - : teams - )?.map((team) => ( - - {`${team.team_alias} (${team.team_id})`} - - ))} + {(selectedOrganizationId ? teams?.filter((t) => t.organization_id === selectedOrganizationId) : teams)?.map( + (team) => ( + + {`${team.team_alias} (${team.team_id})`} + + ), + )} {enableProjectsUI && hasProject && ( diff --git a/uv.lock b/uv.lock index eed58c76bc..ab9aba1e38 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-05-02T11:18:44.200141Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" [manifest] @@ -3189,7 +3189,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.84.0" +version = "1.85.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -3374,7 +3374,7 @@ proxy-dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extra-proxy'", specifier = "==0.3.24" }, - { name = "aiohttp", specifier = "==3.13.4" }, + { name = "aiohttp", specifier = ">=3.10,<4.0" }, { name = "anthropic", extras = ["vertex"], marker = "extra == 'proxy-runtime'", specifier = "==0.84.0" }, { name = "apscheduler", marker = "extra == 'proxy'", specifier = "==3.11.2" }, { name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" }, @@ -3387,14 +3387,14 @@ requires-dist = [ { name = "azure-storage-file-datalake", marker = "extra == 'proxy-runtime'", specifier = "==12.20.0" }, { name = "backoff", marker = "extra == 'proxy'", specifier = "==2.2.1" }, { name = "boto3", marker = "extra == 'proxy'", specifier = "==1.43.1" }, - { name = "click", specifier = "==8.1.8" }, + { name = "click", specifier = ">=8.0.0,<9.0" }, { name = "cryptography", marker = "extra == 'proxy'", specifier = "==46.0.7" }, { name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = "==2.19.0" }, { name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = "==1.5.0" }, { name = "diskcache", marker = "extra == 'caching'", specifier = "==5.6.3" }, { name = "fastapi", marker = "extra == 'proxy'", specifier = "==0.124.4" }, { name = "fastapi-sso", marker = "extra == 'proxy'", specifier = "==0.19.0" }, - { name = "fastuuid", specifier = "==0.14.0" }, + { name = "fastuuid", specifier = ">=0.14.0,<1.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = "==1.133.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'proxy-runtime'", specifier = "==1.133.0" }, { name = "google-cloud-iam", marker = "extra == 'extra-proxy'", specifier = "==2.19.1" }, @@ -3403,10 +3403,10 @@ requires-dist = [ { name = "grpcio", marker = "extra == 'grpc'", specifier = "==1.78.0" }, { name = "grpcio", marker = "extra == 'proxy-runtime'", specifier = "==1.78.0" }, { name = "gunicorn", marker = "extra == 'proxy'", specifier = "==23.0.0" }, - { name = "httpx", specifier = "==0.28.1" }, - { name = "importlib-metadata", specifier = "==8.5.0" }, - { name = "jinja2", specifier = "==3.1.6" }, - { name = "jsonschema", specifier = "==4.23.0" }, + { name = "httpx", specifier = ">=0.28.0,<1.0" }, + { name = "importlib-metadata", specifier = ">=8.0.0,<9.0" }, + { name = "jinja2", specifier = ">=3.1.6,<4.0" }, + { name = "jsonschema", specifier = ">=4.0.0,<5.0" }, { name = "langfuse", marker = "extra == 'proxy-runtime'", specifier = "==2.59.7" }, { name = "litellm-enterprise", marker = "extra == 'proxy'", editable = "enterprise" }, { name = "litellm-proxy-extras", marker = "extra == 'proxy'", editable = "litellm-proxy-extras" }, @@ -3417,7 +3417,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'stt-nvidia-riva'", specifier = ">=1.26.0" }, { name = "numpydoc", marker = "extra == 'utils'", specifier = "==1.8.0" }, { name = "nvidia-riva-client", marker = "extra == 'stt-nvidia-riva'", specifier = ">=2.15.0" }, - { name = "openai", specifier = "==2.33.0" }, + { name = "openai", specifier = ">=2.20.0,<3.0.0" }, { name = "opentelemetry-api", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" }, { name = "opentelemetry-exporter-otlp", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" }, { name = "opentelemetry-sdk", marker = "extra == 'proxy-runtime'", specifier = "==1.28.0" }, @@ -3425,12 +3425,12 @@ requires-dist = [ { name = "polars", marker = "extra == 'proxy'", specifier = "==1.38.1" }, { name = "prisma", marker = "extra == 'extra-proxy'", specifier = "==0.11.0" }, { name = "prometheus-client", marker = "extra == 'proxy-runtime'", specifier = "==0.20.0" }, - { name = "pydantic", specifier = "==2.12.5" }, + { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = "==2.12.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = "==1.6.2" }, { name = "pypdf", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = "==6.10.2" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = "==0.8.16" }, - { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "python-dotenv", specifier = ">=1.0.0,<2.0" }, { name = "python-multipart", marker = "extra == 'proxy'", specifier = "==0.0.27" }, { name = "pyyaml", marker = "extra == 'proxy'", specifier = "==6.0.3" }, { name = "redisvl", marker = "python_full_version < '3.14' and extra == 'extra-proxy'", specifier = "==0.4.1" }, @@ -3442,8 +3442,8 @@ requires-dist = [ { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = "==2.21.0" }, { name = "soundfile", marker = "extra == 'proxy'", specifier = "==0.12.1" }, { name = "soundfile", marker = "extra == 'stt-nvidia-riva'", specifier = ">=0.12.1" }, - { name = "tiktoken", specifier = "==0.12.0" }, - { name = "tokenizers", specifier = "==0.23.1" }, + { name = "tiktoken", specifier = ">=0.8.0,<1.0" }, + { name = "tokenizers", specifier = ">=0.21.0,<1.0" }, { name = "uvicorn", marker = "extra == 'proxy'", specifier = "==0.33.0" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = "==0.21.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = "==15.0.1" },