Merge remote-tracking branch 'origin/dev' into danny-avila/skill-file-authoring-tools

# Conflicts:
#	packages/api/src/agents/initialize.ts
This commit is contained in:
Danny Avila 2026-06-01 19:07:55 -04:00
commit 9ff831e514
78 changed files with 3143 additions and 1721 deletions

View file

@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24.16.0-bullseye
RUN useradd -m -s /bin/bash vscode
RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces

View file

@ -5,7 +5,7 @@
# startup. A fresh index only requires rsync + container restart — no
# image rebuild on every push.
FROM node:24-slim
FROM node:24.16.0-slim
ARG GITNEXUS_VERSION=1.5.3

View file

@ -26,7 +26,7 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 1. Development Setup
1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0.
1. Use Node.js v24.16.0.
2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install.
3. Build all compiled code: `npm run build`.
4. Setup and run unit tests:

237
.github/scripts/sync-helm-chart-tags.sh vendored Executable file
View file

@ -0,0 +1,237 @@
#!/usr/bin/env bash
set -euo pipefail
CHART_PATH="${CHART_PATH:-helm/librechat/Chart.yaml}"
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
BASE_REF="${BASE_REF:-refs/remotes/origin/${DEFAULT_BRANCH}}"
BACKFILL_FROM_VERSION="${BACKFILL_FROM_VERSION:-1.9.0}"
PUSH_TAGS="${PUSH_TAGS:-false}"
TAG_PREFIX="${TAG_PREFIX:-chart-}"
GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}"
DISPATCH_WORKFLOW="${DISPATCH_WORKFLOW:-}"
RELEASE_EXISTING_TAG="${RELEASE_EXISTING_TAG:-}"
SEMVER_REGEX='^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$'
fail() {
printf '::error::%s\n' "$1" >&2
exit 1
}
git_auth_header() {
token="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
printf 'AUTHORIZATION: basic %s' "$token"
}
git_with_auth() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
git -c "http.extraheader=$(git_auth_header)" "$@"
return
fi
git "$@"
}
dispatch_release() {
tag="$1"
if [ -z "$DISPATCH_WORKFLOW" ]; then
return
fi
if [ -z "${GITHUB_REPOSITORY:-}" ]; then
fail "GITHUB_REPOSITORY is required to dispatch ${DISPATCH_WORKFLOW}"
fi
if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
fail "Unexpected repository name: ${GITHUB_REPOSITORY}"
fi
if [[ ! "$DISPATCH_WORKFLOW" =~ ^[A-Za-z0-9_.-]+[.]ya?ml$ ]]; then
fail "Unexpected workflow file: ${DISPATCH_WORKFLOW}"
fi
token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
if [ -z "$token" ]; then
fail "GH_TOKEN or GITHUB_TOKEN is required to dispatch ${DISPATCH_WORKFLOW}"
fi
command -v gh >/dev/null ||
fail "GitHub CLI is required to dispatch ${DISPATCH_WORKFLOW}"
GH_TOKEN="$token" gh workflow run "$DISPATCH_WORKFLOW" \
--repo "$GITHUB_REPOSITORY" \
--ref "$DEFAULT_BRANCH" \
-f "chart_tag=${tag}"
}
version_less_than() {
left="${1%%[-+]*}"
right="${2%%[-+]*}"
IFS=. read -r left_major left_minor left_patch <<<"$left"
IFS=. read -r right_major right_minor right_patch <<<"$right"
if (( left_major != right_major )); then
(( left_major < right_major ))
return
fi
if (( left_minor != right_minor )); then
(( left_minor < right_minor ))
return
fi
(( left_patch < right_patch ))
}
validate_chart_tag() {
tag="$1"
version="${tag#${TAG_PREFIX}}"
git check-ref-format "refs/tags/${tag}" >/dev/null ||
fail "Refusing to use invalid tag ${tag}"
if [[ "$tag" != "${TAG_PREFIX}"* || ! "$version" =~ $SEMVER_REGEX ]]; then
fail "Chart tags must use the form ${TAG_PREFIX}<semver>, for example ${TAG_PREFIX}2.0.5"
fi
}
dispatch_existing_tag() {
tag="$1"
if [ -z "$tag" ]; then
return
fi
validate_chart_tag "$tag"
if [ "$PUSH_TAGS" != "true" ]; then
printf 'Would dispatch release workflow for existing %s.\n' "$tag"
return
fi
if ! git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
fail "Remote tag ${tag} does not exist"
fi
printf 'Dispatching release workflow for existing %s.\n' "$tag"
dispatch_release "$tag"
}
chart_version_at() {
git show "${1}:${CHART_PATH}" 2>/dev/null | awk '
/^version:[[:space:]]*/ {
value = $0
sub(/^version:[[:space:]]*/, "", value)
sub(/[[:space:]]*#.*/, "", value)
gsub(/^[[:space:]"'\''"]+|[[:space:]"'\''"]+$/, "", value)
print value
exit
}
'
}
case "$PUSH_TAGS" in
true | false) ;;
*) fail "PUSH_TAGS must be true or false" ;;
esac
if [[ ! "$BACKFILL_FROM_VERSION" =~ $SEMVER_REGEX ]]; then
fail "BACKFILL_FROM_VERSION must be a valid SemVer value"
fi
git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null ||
fail "Unable to resolve ${BASE_REF}; fetch ${DEFAULT_BRANCH} before running this script"
history_file="$(mktemp)"
versions_file="$(mktemp)"
seen_file="$(mktemp)"
missing_file="$(mktemp)"
cleanup() {
rm -f "$history_file" "$versions_file" "$seen_file" "$missing_file"
}
trap cleanup EXIT
git log --first-parent --reverse --format=%H "$BASE_REF" -- "$CHART_PATH" >"$history_file"
if [ ! -s "$history_file" ]; then
fail "No history found for ${CHART_PATH} on ${BASE_REF}"
fi
while IFS= read -r commit; do
version="$(chart_version_at "$commit")"
if [ -z "$version" ]; then
continue
fi
if [[ ! "$version" =~ $SEMVER_REGEX ]]; then
fail "${CHART_PATH} has invalid SemVer '${version}' at ${commit}"
fi
if version_less_than "$version" "$BACKFILL_FROM_VERSION"; then
continue
fi
if grep -Fqx "$version" "$seen_file"; then
continue
fi
printf '%s\n' "$version" >>"$seen_file"
printf '%s\t%s\n' "$version" "$commit" >>"$versions_file"
done <"$history_file"
if [ ! -s "$versions_file" ]; then
fail "No chart versions found in ${CHART_PATH}"
fi
while IFS="$(printf '\t')" read -r version commit; do
tag="${TAG_PREFIX}${version}"
validate_chart_tag "$tag"
if git rev-parse --quiet --verify "refs/tags/${tag}" >/dev/null; then
continue
fi
printf '%s\t%s\n' "$tag" "$commit" >>"$missing_file"
done <"$versions_file"
if [ ! -s "$missing_file" ]; then
printf 'All chart versions on %s already have %s tags.\n' "$BASE_REF" "$TAG_PREFIX"
dispatch_existing_tag "$RELEASE_EXISTING_TAG"
exit 0
fi
while IFS="$(printf '\t')" read -r tag commit; do
short_commit="$(git rev-parse --short "$commit")"
if [ "$PUSH_TAGS" != "true" ]; then
printf 'Would create %s at %s.\n' "$tag" "$short_commit"
continue
fi
if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
printf 'Remote tag %s already exists; dispatching release workflow.\n' "$tag"
dispatch_release "$tag"
continue
fi
git tag "$tag" "$commit"
if git_with_auth push origin "refs/tags/${tag}"; then
printf 'Created %s at %s.\n' "$tag" "$short_commit"
dispatch_release "$tag"
continue
fi
if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then
printf 'Remote tag %s was created concurrently; dispatching release workflow.\n' "$tag"
dispatch_release "$tag"
continue
fi
fail "Failed to push ${tag}"
done <"$missing_file"
dispatch_existing_tag "$RELEASE_EXISTING_TAG"

View file

@ -20,10 +20,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -35,7 +35,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -103,10 +103,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -118,7 +118,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -162,10 +162,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -177,7 +177,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -231,10 +231,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -246,7 +246,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -289,10 +289,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -304,7 +304,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -327,10 +327,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -342,7 +342,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -375,10 +375,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -390,7 +390,7 @@ jobs:
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'

View file

@ -28,10 +28,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
cache: 'npm'
- name: Install Redis tools

View file

@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '24.16.0'
- name: Install client dependencies
run: cd packages/client && npm ci
@ -77,7 +77,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
- name: Install npm with OIDC support

View file

@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
- run: cd packages/data-provider && npm ci
- run: cd packages/data-provider && npm run build
- name: Pack package
@ -50,7 +50,7 @@ jobs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
- name: Install npm with OIDC support

View file

@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '24.16.0'
- name: Install dependencies
run: cd packages/data-schemas && npm ci
@ -77,7 +77,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
- name: Install npm with OIDC support

View file

@ -27,10 +27,10 @@ jobs:
with:
fetch-depth: 0
- name: Set up Node.js 20.x
- name: Set up Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
cache: npm
- name: Install dependencies

View file

@ -20,10 +20,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -34,7 +34,7 @@ jobs:
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -84,10 +84,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -98,7 +98,7 @@ jobs:
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -128,10 +128,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -142,7 +142,7 @@ jobs:
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
@ -172,10 +172,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.19
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: '20.19'
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
@ -186,7 +186,7 @@ jobs:
client/node_modules
packages/client/node_modules
packages/data-provider/node_modules
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'

View file

@ -136,7 +136,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
node-version: '24.16.0'
- name: Install GitNexus CLI
working-directory: ${{ runner.temp }}

View file

@ -5,18 +5,54 @@ on:
push:
tags:
- "chart-*"
workflow_dispatch:
inputs:
chart_tag:
description: "Existing chart tag to release, for example chart-2.0.5"
required: true
type: string
jobs:
release:
permissions:
contents: write
contents: read
packages: write
runs-on: ubuntu-latest
env:
CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart
steps:
- name: Resolve chart tag
id: chart-version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_CHART_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.chart_tag || '' }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
CHART_TAG="$REF_NAME"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
CHART_TAG="$INPUT_CHART_TAG"
fi
CHART_VERSION="${CHART_TAG#chart-}"
SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$'
if [[ "$CHART_TAG" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error::Chart tags must use the form chart-<semver>, for example chart-2.0.3"
exit 1
fi
{
printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG"
printf 'CHART_TAG=%s\n' "$CHART_TAG"
printf 'CHART_VERSION=%s\n' "$CHART_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ steps.chart-version.outputs.CHART_REF }}
- name: Configure Git
run: |
@ -35,20 +71,6 @@ jobs:
cd ../librechat-rag-api
helm dependency build
- name: Get Chart Version
id: chart-version
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
CHART_VERSION="${REF_NAME#chart-}"
SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$'
if [[ "$REF_NAME" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error::Chart tags must use the form chart-<semver>, for example chart-2.0.3"
exit 1
fi
printf 'CHART_VERSION=%s\n' "$CHART_VERSION" >> "$GITHUB_OUTPUT"
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@ -63,7 +85,7 @@ jobs:
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat
repository: ${{ github.actor }}/librechat-chart
repository: ${{ env.CHART_REPOSITORY }}
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat
registry: ghcr.io
@ -75,7 +97,7 @@ jobs:
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat-rag-api
repository: ${{ github.actor }}/librechat-chart
repository: ${{ env.CHART_REPOSITORY }}
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat-rag-api
registry: ghcr.io

View file

@ -22,7 +22,7 @@ jobs:
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
- name: Install locize CLI
run: npm install -g locize-cli

View file

@ -0,0 +1,85 @@
name: Sync Helm Chart Tags
on:
push:
branches:
- main
workflow_dispatch:
inputs:
release_existing_tag:
description: "Existing chart-* tag to dispatch if tag creation succeeded but release dispatch failed"
required: false
type: string
permissions:
contents: read
concurrency:
group: sync-helm-chart-tags
cancel-in-progress: false
jobs:
noop:
name: Ignore non-main push
if: github.event_name == 'push' && github.ref != 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Skip tag sync
run: echo "Helm chart tag sync only runs on main pushes or manual dispatch."
sync:
name: Sync chart tags
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
contents: write
env:
BASE_REF: refs/remotes/origin/main
BACKFILL_FROM_VERSION: 1.9.0
CHART_PATH: helm/librechat/Chart.yaml
DEFAULT_BRANCH: main
DISPATCH_WORKFLOW: helmcharts.yml
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_EXISTING_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_existing_tag || '' }}
REPO_DIR: /tmp/librechat-sync
TAG_PREFIX: chart-
steps:
- name: Fetch main and tags
shell: bash
run: |
set -euo pipefail
if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "::error::Unexpected repository name: $GITHUB_REPOSITORY"
exit 1
fi
if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then
echo "::error::Unexpected GitHub server URL: $GITHUB_SERVER_URL"
exit 1
fi
rm -rf "$REPO_DIR"
git init "$REPO_DIR"
cd "$REPO_DIR"
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
AUTH_HEADER="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
git -c "http.extraheader=AUTHORIZATION: basic ${AUTH_HEADER}" \
fetch --prune --force --tags origin \
"+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}"
git checkout --detach "$BASE_REF"
- name: Create missing chart tags
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PUSH_TAGS: "true"
run: |
set -euo pipefail
cd "$REPO_DIR"
.github/scripts/sync-helm-chart-tags.sh

View file

@ -20,10 +20,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js 20.x
- name: Use Node.js 24.16.0
uses: actions/setup-node@v4
with:
node-version: 20
node-version: '24.16.0'
cache: 'npm'
- name: Install depcheck

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24.16.0

View file

@ -143,7 +143,7 @@ Multi-line imports count total character length across all lines. Consolidate va
| `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) |
| `npm run build:data-provider` | Rebuild `packages/data-provider` after changes |
- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0
- Node.js: v24.16.0
- Database: MongoDB
- Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/`

View file

@ -1,7 +1,7 @@
# v0.8.6
# Base node image
FROM node:20-alpine AS node
FROM node:24.16.0-alpine AS node
RUN apk upgrade --no-cache
RUN apk add --no-cache jemalloc

View file

@ -10,7 +10,7 @@ ARG BUILD_BRANCH=
ARG BUILD_DATE=
# Base for all builds
FROM node:20-alpine AS base-min
FROM node:24.16.0-alpine AS base-min
ARG NPM_CI_TIMEOUT_SECONDS=1500
ARG NPM_CI_ATTEMPTS=2
RUN apk upgrade --no-cache

View file

@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => {
authField: key,
label: value.title || key,
description: value.description || '',
sensitive: value.sensitive,
}));
server.authenticated = false;
}

View file

@ -177,7 +177,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
// Validate and fill out missing values for custom parameters
function parseCustomParams(endpointName, customParams) {
const paramEndpoint = customParams.defaultParamsEndpoint;
const paramEndpoint = customParams.defaultParamsEndpoint ?? 'custom';
customParams.defaultParamsEndpoint = paramEndpoint;
customParams.paramDefinitions = customParams.paramDefinitions || [];
// Checks if `defaultParamsEndpoint` is a key in `paramSettings`.

View file

@ -11,7 +11,7 @@ jest.mock('librechat-data-provider', () => {
paramSettings: {
foo: {},
bar: {},
custom: {},
custom: [],
openrouter: [
{
key: 'promptCache',
@ -59,6 +59,7 @@ jest.mock('@librechat/data-schemas', () => {
const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider');
const loadCustomConfig = require('./loadCustomConfig');
describe('loadCustomConfig', () => {
@ -307,11 +308,28 @@ describe('loadCustomConfig', () => {
);
});
it('throws an error when defaultParamsEndpoint is not provided', async () => {
const malformedCustomParams = { defaultParamsEndpoint: undefined };
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, openrouter, google',
);
it('defaults defaultParamsEndpoint when only reasoningFormat is provided', async () => {
const parsedConfig = await loadCustomParams({
reasoningFormat: ReasoningParameterFormat.reasoningObject,
});
expect(parsedConfig.endpoints.custom[0].customParams).toEqual({
defaultParamsEndpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.reasoningObject,
paramDefinitions: [],
});
});
it('defaults defaultParamsEndpoint when only reasoningKey is provided', async () => {
const parsedConfig = await loadCustomParams({
reasoningKey: ReasoningResponseKey.reasoning,
});
expect(parsedConfig.endpoints.custom[0].customParams).toEqual({
defaultParamsEndpoint: 'custom',
reasoningKey: ReasoningResponseKey.reasoning,
paramDefinitions: [],
});
});
it('fills the paramDefinitions with missing values', async () => {

View file

@ -111,7 +111,7 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-supersub": "^1.0.0",
"sse.js": "^2.5.0",
"sse.js": "^2.8.0",
"swr": "^2.3.8",
"tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5",
@ -133,10 +133,10 @@
"@types/jest": "^29.5.14",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.15",
"@types/node": "^20.19.35",
"@types/node": "^24.12.4",
"@types/react": "^18.2.11",
"@types/react-dom": "^18.2.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.4.20",
"babel-plugin-replace-ts-export-assignment": "^0.0.2",
"babel-plugin-root-import": "^6.6.0",
@ -155,9 +155,9 @@
"postcss-preset-env": "^11.2.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^7.3.1",
"vite-plugin-compression2": "^2.2.1",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-pwa": "^1.2.0"
"vite": "^8.0.16",
"vite-plugin-compression2": "^2.5.3",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-pwa": "^1.3.0"
}
}

View file

@ -16,6 +16,8 @@ export function isEphemeralAgent(agentId: string | null | undefined): boolean {
export interface ConfigFieldDetail {
title: string;
description: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
export type CodeBarProps = {

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client';
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
@ -31,6 +31,13 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
const useUsernameLogin = config?.ldap?.username;
const validTheme = isDark(theme) ? 'dark' : 'light';
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
useEffect(() => {
if (error && error.includes('422') && !showResendLink) {
@ -102,13 +109,10 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
: (value) => validateEmail(value, localize('com_auth_email_pattern')),
})}
aria-invalid={!!errors.email}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
/>
<label
htmlFor="email"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
<label htmlFor="email" className={authLabelClassName}>
{useUsernameLogin
? localize('com_auth_username').replace(/ \(.*$/, '')
: localize('com_auth_email_address')}
@ -118,8 +122,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
<SecretInput
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
@ -132,15 +135,13 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authSecretInputClassName}
placeholder=" "
label={localize('com_auth_password')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{renderError('password')}
</div>

View file

@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form';
import React, { useContext, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
@ -36,6 +36,13 @@ const Registration: React.FC = () => {
// only require captcha if we have a siteKey
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
const authInputClassName =
'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authSecretInputClassName = `${authInputClassName} h-auto pr-12`;
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
const registerUser = useRegisterUserMutation({
onMutate: () => {
@ -64,37 +71,58 @@ const Registration: React.FC = () => {
},
});
const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => (
<div className="mb-4">
<div className="relative">
<input
id={id}
type={type}
autoComplete={id}
aria-label={localize(label)}
{...register(
id as 'name' | 'email' | 'username' | 'password' | 'confirm_password',
validation,
const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => {
const fieldLabel = localize(label);
const field = register(
id as 'name' | 'email' | 'username' | 'password' | 'confirm_password',
validation,
);
return (
<div className="mb-4">
<div className="relative">
{type === 'password' ? (
<SecretInput
id={id}
autoComplete={id}
aria-label={fieldLabel}
{...field}
aria-invalid={!!errors[id]}
className={authSecretInputClassName}
placeholder=" "
data-testid={id}
label={fieldLabel}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
) : (
<>
<input
id={id}
type={type}
autoComplete={id}
aria-label={fieldLabel}
{...field}
aria-invalid={!!errors[id]}
className={authInputClassName}
placeholder=" "
data-testid={id}
/>
<label htmlFor={id} className={authLabelClassName}>
{fieldLabel}
</label>
</>
)}
aria-invalid={!!errors[id]}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
data-testid={id}
/>
<label
htmlFor={id}
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize(label)}
</label>
</div>
{errors[id] && (
<span role="alert" className="mt-1 text-sm text-red-500">
{String(errors[id]?.message) ?? ''}
</span>
)}
</div>
{errors[id] && (
<span role="alert" className="mt-1 text-sm text-red-500">
{String(errors[id]?.message) ?? ''}
</span>
)}
</div>
);
);
};
return (
<>

View file

@ -1,5 +1,5 @@
import { useForm } from 'react-hook-form';
import { Spinner, Button } from '@librechat/client';
import { Spinner, Button, SecretInput } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useResetPasswordMutation } from 'librechat-data-provider/react-query';
@ -20,6 +20,12 @@ function ResetPassword() {
const password = watch('password');
const resetPassword = useResetPasswordMutation();
const { setError, setHeaderText, startupConfig } = useOutletContext<TLoginLayoutContext>();
const authInputClassName =
'webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pr-12 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500';
const authLabelClassName =
'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4';
const authSecretButtonClassName =
'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary';
const onSubmit = (data: TResetPassword) => {
resetPassword.mutate(data, {
@ -75,8 +81,7 @@ function ResetPassword() {
value={params.get('userId') ?? ''}
{...register('userId', { required: 'Unable to process: No valid user id' })}
/>
<input
type="password"
<SecretInput
id="password"
autoComplete="current-password"
aria-label={localize('com_auth_password')}
@ -92,15 +97,13 @@ function ResetPassword() {
},
})}
aria-invalid={!!errors.password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
label={localize('com_auth_password')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
</div>
{errors.password && (
@ -111,23 +114,20 @@ function ResetPassword() {
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
<SecretInput
id="confirm_password"
aria-label={localize('com_auth_password_confirm')}
{...register('confirm_password', {
validate: (value) => value === password || localize('com_auth_password_not_match'),
})}
aria-invalid={!!errors.confirm_password}
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
className={authInputClassName}
placeholder=" "
label={localize('com_auth_password_confirm')}
labelClassName={authLabelClassName}
controlsClassName="right-2"
buttonClassName={authSecretButtonClassName}
/>
<label
htmlFor="confirm_password"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password_confirm')}
</label>
</div>
{errors.confirm_password && (
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">

View file

@ -1,6 +1,6 @@
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
import { Button, Input, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
import type { ConfigFieldDetail } from '~/common';
import {
CONFIG_HTML_BLOCK_TAGS,
@ -84,15 +84,34 @@ export default function MCPConfigDialog({
name={key}
control={control}
defaultValue={initialValues[key] || ''}
render={({ field }) => (
<Input
id={key}
type="text"
{...field}
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
render={({ field }) => {
const placeholder = localize('com_ui_mcp_enter_var', { 0: details.title });
const className =
'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm';
if (details.sensitive === false) {
return (
<Input
id={key}
{...field}
type="text"
placeholder={placeholder}
className={className}
/>
);
}
return (
<SecretInput
id={key}
{...field}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={placeholder}
className={className}
/>
);
}}
/>
{details.description && (
<p

View file

@ -22,6 +22,7 @@ const CustomEndpoint = ({
label={`${endpoint} API Key`}
labelClassName="mb-1"
inputClassName="mb-2"
secret
/>
)}
/>

View file

@ -53,6 +53,7 @@ const GoogleConfig = ({ userKey, setUserKey }: Pick<TConfigProps, 'userKey' | 's
}
label={localize('com_endpoint_config_google_api_key')}
subLabel={localize('com_endpoint_config_google_gemini_api')}
secret
/>
</>
);

View file

@ -1,7 +1,7 @@
import { forwardRef } from 'react';
import { Input, Label } from '@librechat/client';
import { Input, Label, SecretInput } from '@librechat/client';
import type { ChangeEvent, FC, Ref } from 'react';
import { cn, defaultTextPropsLabel, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { cn } from '~/utils/';
import { useLocalize } from '~/hooks';
interface InputWithLabelProps {
@ -12,11 +12,21 @@ interface InputWithLabelProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
labelClassName?: string;
inputClassName?: string;
secret?: boolean;
ref?: Ref<HTMLInputElement>;
}
const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
const { id, value, label, subLabel, onChange, labelClassName = '', inputClassName = '' } = props;
const {
id,
value,
label,
secret = false,
subLabel,
onChange,
labelClassName = '',
inputClassName = '',
} = props;
const localize = useLocalize();
return (
<>
@ -24,19 +34,37 @@ const InputWithLabel: FC<InputWithLabelProps> = forwardRef((props, ref) => {
<Label htmlFor={id} className="text-left text-sm font-medium">
{label}
</Label>
{Label && <Label className="mx-1 text-right text-sm text-text-secondary">{subLabel}</Label>}
{subLabel && (
<Label className="mx-1 text-right text-sm text-text-secondary">{subLabel}</Label>
)}
<br />
</div>
<div className="h-1" />
<Input
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2')}
/>
{secret ? (
<SecretInput
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2', inputClassName)}
/>
) : (
<Input
id={id}
data-testid={`input-${id}`}
value={value ?? ''}
onChange={onChange}
ref={ref}
placeholder={`${localize('com_endpoint_config_value')} ${label}`}
className={cn('flex h-10 max-h-10 w-full resize-none px-3 py-2', inputClassName)}
/>
)}
</>
);
});

View file

@ -24,6 +24,7 @@ const OpenAIConfig = ({
label={`${isAzure ? 'Azure q' : ''}OpenAI API Key`}
labelClassName="mb-1"
inputClassName="mb-2"
secret
/>
)}
/>
@ -39,6 +40,7 @@ const OpenAIConfig = ({
{...field}
label={'Azure OpenAI API Key'}
labelClassName="mb-1"
secret
/>
)}
/>

View file

@ -11,6 +11,7 @@ const OtherConfig = ({ userKey, setUserKey, endpoint }: TConfigProps) => {
value={userKey ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUserKey(e.target.value ?? '')}
label={localize('com_endpoint_config_key_name')}
secret
/>
);
};

View file

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, Button } from '@librechat/client';
import { Label, Input, Button, SecretInput } from '@librechat/client';
import type { Control, FieldErrors } from 'react-hook-form';
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
import {
CONFIG_HTML_INLINE_TAGS,
@ -12,6 +13,8 @@ import { useLocalize } from '~/hooks';
export interface CustomUserVarConfig {
title: string;
description?: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
interface CustomUserVarsSectionProps {
@ -25,8 +28,8 @@ interface AuthFieldProps {
name: string;
config: CustomUserVarConfig;
hasValue: boolean;
control: any;
errors: any;
control: Control<Record<string, string>>;
errors: FieldErrors<Record<string, string>>;
autoFocus?: boolean;
}
@ -70,29 +73,30 @@ function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthF
name={name}
control={control}
defaultValue=""
render={({ field }) => (
<Input
id={name}
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
type="new-password"
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field would logically be immediately navigated to anyways, and the component's
* functionality emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...field}
placeholder={
hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title })
}
className="w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm"
/>
)}
render={({ field }) => {
const placeholder = hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title });
const className =
'w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm';
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
const sharedProps = {
id: name,
'data-lpignore': 'true',
'data-1p-ignore': 'true',
/* autoFocus is generally disorienting, but here the required field is navigated to
* anyway, and the section emulates a modal opening where users expect focus to shift. */
autoFocus,
...field,
placeholder,
className,
};
if (config.sensitive === false) {
return <Input {...sharedProps} type="text" autoComplete="off" />;
}
return <SecretInput {...sharedProps} autoComplete="new-password" controlsOnHover />;
}}
/>
{sanitizedDescription && (
<p

View file

@ -27,8 +27,25 @@ describe('CustomUserVarsSection', () => {
const input = screen.getByLabelText(/My API Key/);
expect(input).toHaveAttribute('autocomplete', 'new-password');
expect(input).toHaveAttribute('type', 'new-password');
expect(input).toHaveAttribute('type', 'password');
expect(input).toHaveAttribute('data-lpignore', 'true');
expect(input).toHaveAttribute('data-1p-ignore', 'true');
});
it('renders non-sensitive fields as unmasked text while keeping secrets masked', () => {
render(
<CustomUserVarsSection
serverName="test-server"
fields={{
api_key: { title: 'My API Key', description: 'Your API key' },
project_key: { title: 'Project Key', description: 'Your project key', sensitive: false },
}}
onSave={jest.fn()}
onRevoke={jest.fn()}
/>,
);
expect(screen.getByLabelText(/My API Key/)).toHaveAttribute('type', 'password');
expect(screen.getByLabelText(/Project Key/)).toHaveAttribute('type', 'text');
});
});

View file

@ -1,10 +1,8 @@
import React, { useState } from 'react';
import React from 'react';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, Check } from 'lucide-react';
import { Input, Button, Label } from '@librechat/client';
import { Button, Label, SecretInput } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const fadeAnimation = {
initial: { opacity: 0, y: 20 },
@ -23,13 +21,6 @@ interface QRPhaseProps {
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
const localize = useLocalize();
const [isCopying, setIsCopying] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(secret);
setIsCopying(true);
setTimeout(() => setIsCopying(false), 2000);
};
return (
<motion.div {...fadeAnimation} className="space-y-6">
@ -45,21 +36,14 @@ export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext })
<Label className="text-sm font-medium text-text-secondary">
{localize('com_ui_secret_key')}
</Label>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
<Button
size="sm"
variant="outline"
onClick={handleCopy}
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</Button>
</div>
<SecretInput
value={secret}
readOnly
showCopy
controlsOnHover
aria-label={localize('com_ui_secret_key')}
className="font-mono text-lg tracking-wider"
/>
</div>
</div>
<Button onClick={onNext} className="w-full">

View file

@ -5,12 +5,13 @@ import {
useDeleteAgentApiKeyMutation,
} from 'librechat-data-provider/react-query';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { Plus, Trash2, Copy, CopyCheck, Key, Eye, EyeOff, ShieldEllipsis } from 'lucide-react';
import { Plus, Trash2, Key, ShieldEllipsis } from 'lucide-react';
import {
Button,
Input,
Label,
Spinner,
SecretInput,
OGDialog,
OGDialogClose,
OGDialogTitle,
@ -21,7 +22,7 @@ import {
} from '@librechat/client';
import type { PermissionConfig } from '~/components/ui';
import { useUpdateRemoteAgentsPermissionsMutation } from '~/data-provider';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { useLocalize } from '~/hooks';
import { AdminSettingsDialog } from '~/components/ui';
function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
@ -30,10 +31,7 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [newKey, setNewKey] = useState<string | null>(null);
const [showKey, setShowKey] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const createMutation = useCreateAgentApiKeyMutation();
const copyKey = useCopyToClipboard({ text: newKey || '' });
const handleCreate = async () => {
if (!name.trim()) {
@ -54,15 +52,10 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
const handleClose = () => {
setName('');
setNewKey(null);
setShowKey(false);
setOpen(false);
};
const handleCopy = () => {
if (isCopying) {
return;
}
copyKey(setIsCopying);
showToast({ message: localize('com_ui_api_key_copied'), status: 'success' });
};
@ -112,30 +105,15 @@ function CreateKeyDialog({ onKeyCreated }: { onKeyCreated?: () => void }) {
</div>
<div className="space-y-2">
<Label>{localize('com_ui_your_api_key')}</Label>
<div className="flex gap-2">
<Input
value={showKey ? newKey : '•'.repeat(newKey.length)}
readOnly
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={() => setShowKey(!showKey)}
title={showKey ? localize('com_ui_hide') : localize('com_ui_show')}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
disabled={isCopying}
title={localize('com_ui_copy')}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<SecretInput
value={newKey}
readOnly
showCopy
controlsOnHover
onCopy={handleCopy}
aria-label={localize('com_ui_your_api_key')}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleClose}>{localize('com_ui_done')}</Button>

View file

@ -1,6 +1,6 @@
import { Save } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { HoverCard, HoverCardTrigger } from '@librechat/client';
import { HoverCard, HoverCardTrigger, SecretInput } from '@librechat/client';
import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider';
import PluginTooltip from './PluginTooltip';
import { useLocalize } from '~/hooks';
@ -40,6 +40,31 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
{authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0];
const isOptional = config.optional === true;
const inputClassName =
'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0';
const sharedProps = {
id: authField,
'aria-invalid': !!errors[authField],
'aria-describedby': `${authField}-error`,
'aria-label': config.label,
'aria-required': !isOptional,
/* autoFocus is generally disorienting, but here the required field must be navigated to
* anyway, and the form emulates a modal opening where users expect focus to shift. */
autoFocus: i === 0,
className: inputClassName,
...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
),
};
return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
<label
@ -50,33 +75,17 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
</label>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<input
type="text"
autoComplete="off"
id={authField}
aria-invalid={!!errors[authField]}
aria-describedby={`${authField}-error`}
aria-label={config.label}
aria-required={!isOptional}
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field must be navigated to anyways, and the component's functionality
* emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={i === 0}
{...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
)}
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
/>
{config.sensitive === false ? (
<input type="text" autoComplete="off" {...sharedProps} />
) : (
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
{...sharedProps}
/>
)}
</HoverCardTrigger>
<PluginTooltip content={config.description} position="right" />
</HoverCard>

View file

@ -23,8 +23,29 @@ describe('PluginAuthForm', () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('Key')).toBeInTheDocument();
expect(screen.getByLabelText('Secret')).toBeInTheDocument();
expect(screen.getByLabelText('Key')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
});
it('masks fields by default and renders non-sensitive fields as plain text', () => {
const mixedPlugin = {
pluginKey: 'mixed-plugin',
authConfig: [
{ authField: 'token', label: 'Token' },
{ authField: 'secret', label: 'Secret', sensitive: true },
{ authField: 'url', label: 'URL', sensitive: false },
],
};
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={mixedPlugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
const urlField = screen.getByLabelText('URL');
expect(urlField).toHaveAttribute('type', 'text');
expect(urlField.parentElement?.querySelector('button')).toBeNull();
});
it('calls the onSubmit function with the form data when submitted', async () => {

View file

@ -1,11 +1,9 @@
import { useState } from 'react';
import * as Menu from '@ariakit/react/menu';
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
import { Input, Label, DropdownPopup } from '@librechat/client';
import { ChevronDown } from 'lucide-react';
import { Input, Label, SecretInput, DropdownPopup } from '@librechat/client';
import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool';
import type { UseFormRegister } from 'react-hook-form';
import type { MenuItemProps } from '~/common';
import { useLocalize } from '~/hooks';
interface InputConfig {
placeholder: string;
@ -45,21 +43,12 @@ export default function InputSection({
setDropdownOpen,
dropdownKey,
}: InputSectionProps) {
const localize = useLocalize();
const [passwordVisibility, setPasswordVisibility] = useState<Record<string, boolean>>({});
const selectedOption = dropdownOptions.find((opt) => opt.key === selectedKey);
const dropdownItems: MenuItemProps[] = dropdownOptions.map((option) => ({
label: option.label,
onClick: () => onSelectionChange(option.key),
}));
const togglePasswordVisibility = (fieldName: string) => {
setPasswordVisibility((prev) => ({
...prev,
[fieldName]: !prev[fieldName],
}));
};
return (
<div className="mb-6">
<div className="mb-2 flex items-center justify-between">
@ -85,47 +74,27 @@ export default function InputSection({
)}
</div>
{selectedOption?.inputs &&
Object.entries(selectedOption.inputs).map(([name, config], index) => (
Object.entries(selectedOption.inputs).map(([name, config]) => (
<div key={name}>
<div className="relative">
<Input
type={'text'} // so password autofill doesn't show
placeholder={config.placeholder}
autoComplete={config.type === 'password' ? 'one-time-code' : 'off'}
readOnly={config.type === 'password'}
onFocus={
config.type === 'password' ? (e) => (e.target.readOnly = false) : undefined
}
className={`${index > 0 ? 'mb-2' : 'mb-2'} ${
config.type === 'password' ? 'pr-10' : ''
}`}
{...register(name as keyof SearchApiKeyFormData)}
/>
{config.type === 'password' && (
<button
type="button"
onClick={() => togglePasswordVisibility(name)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary transition-colors hover:text-text-primary"
aria-label={
passwordVisibility[name]
? localize('com_ui_hide_password')
: localize('com_ui_show_password')
}
>
<div className="relative h-4 w-4">
{passwordVisibility[name] ? (
<EyeOff
className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in"
aria-hidden="true"
/>
) : (
<Eye
className="absolute inset-0 h-4 w-4 duration-200 animate-in fade-in"
aria-hidden="true"
/>
)}
</div>
</button>
{config.type === 'password' ? (
<SecretInput
placeholder={config.placeholder}
autoComplete="one-time-code"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
className="mb-2"
{...register(name as keyof SearchApiKeyFormData)}
/>
) : (
<Input
type="text"
placeholder={config.placeholder}
autoComplete="off"
className="mb-2"
{...register(name as keyof SearchApiKeyFormData)}
/>
)}
</div>
{config.link && (

View file

@ -13,6 +13,7 @@ import {
OGDialogHeader,
OGDialogContent,
OGDialogTrigger,
SecretInput,
} from '@librechat/client';
import { TranslationKeys, useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -23,6 +24,18 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
const { watch, setValue, trigger } = useFormContext();
const type = watch('type');
const renderAuthFields = () => {
if (type === AuthTypeEnum.None) {
return null;
}
if (type === AuthTypeEnum.ServiceHttp) {
return <ApiKey />;
}
return <OAuth />;
};
return (
<OGDialog open={openAuthDialog} onOpenChange={setOpenAuthDialog}>
<OGDialogTrigger asChild>
@ -136,7 +149,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
</div>
</RadioGroup.Root>
</div>
{type === 'none' ? null : type === 'service_http' ? <ApiKey /> : <OAuth />}
{renderAuthFields()}
{/* Cancel/Save */}
<div className="mt-5 flex flex-col gap-3 sm:mt-4 sm:flex-row-reverse">
<button
@ -168,18 +181,20 @@ const ApiKey = () => {
const { register, watch, setValue } = useFormContext();
const authorization_type = watch('authorization_type');
const type = watch('type');
const inputClasses = cn(
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
'border-border-medium bg-surface-primary outline-none',
'focus:ring-2 focus:ring-ring',
);
return (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="new-password"
autoComplete="new-password"
className={cn(
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
'border-border-medium bg-surface-primary outline-none',
'focus:ring-2 focus:ring-ring',
)}
controlsOnHover
className={inputClasses}
{...register('api_key', { required: type === AuthTypeEnum.ServiceHttp })}
/>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_auth_type')}</label>
@ -294,18 +309,18 @@ const OAuth = () => {
return (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_id')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
controlsOnHover
className={inputClasses}
{...register('oauth_client_id', { required: false })}
/>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_client_secret')}</label>
<input
<SecretInput
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
controlsOnHover
className={inputClasses}
{...register('oauth_client_secret', { required: false })}
/>

View file

@ -105,7 +105,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
<Label htmlFor="api_key" className="text-sm font-medium">
{localize('com_ui_api_key')}
</Label>
<SecretInput id="api_key" placeholder="sk-..." {...register('auth.api_key')} />
<SecretInput
id="api_key"
placeholder="sk-..."
controlsOnHover
{...register('auth.api_key')}
/>
</div>
)}
@ -160,9 +165,12 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
</>
)}
</Label>
<Input
<SecretInput
id="oauth_client_id"
autoComplete="off"
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
aria-invalid={errors.auth?.oauth_client_id ? 'true' : 'false'}
aria-describedby={
@ -188,6 +196,7 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
<SecretInput
id="oauth_client_secret"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
controlsOnHover
{...register('auth.oauth_client_secret')}
/>
</div>

View file

@ -2,7 +2,6 @@ export * from './buttons';
export * from './dialogs';
export * from './display';
export * from './forms';
export * from './layouts';
export * from './lists';
export * from './sidebar';
export * from './tree';

View file

@ -562,6 +562,7 @@ export function useMCPServerManager({
authField: key,
label: config.title,
description: config.description,
sensitive: config.sensitive,
}))
: []),
authenticated: serverData?.authenticated ?? false,
@ -609,6 +610,7 @@ export function useMCPServerManager({
fieldsSchema[field.authField] = {
title: field.label || field.authField,
description: field.description,
sensitive: field.sensitive,
};
});
}

View file

@ -1105,7 +1105,6 @@
"com_ui_hide_code": "Hide Code",
"com_ui_hide_image_details": "Hide Image Details",
"com_ui_hide_n_files": "Hide {{0}} files",
"com_ui_hide_password": "Hide password",
"com_ui_hide_qr": "Hide QR Code",
"com_ui_high": "High",
"com_ui_host": "Host",
@ -1492,7 +1491,6 @@
"com_ui_show_less": "Show less",
"com_ui_show_more": "Show more",
"com_ui_show_n_files": "Show {{0}} files",
"com_ui_show_password": "Show password",
"com_ui_show_qr": "Show QR Code",
"com_ui_sign_in_to_domain": "Sign-in to {{0}}",
"com_ui_simple": "Simple",

View file

@ -1,4 +1,4 @@
import 'regenerator-runtime/runtime';
import './polyfills/regeneratorRuntime';
import { createRoot } from 'react-dom/client';
import './locales/i18n';
import App from './App';

View file

@ -0,0 +1,3 @@
import regeneratorRuntime from 'regenerator-runtime/runtime';
globalThis.regeneratorRuntime ??= regeneratorRuntime;

View file

@ -12,7 +12,7 @@ const require = createRequire(import.meta.url);
/**
* vite-plugin-node-polyfills uses @rollup/plugin-inject to replace bare globals (e.g. `process`)
* with imports like `import process from 'vite-plugin-node-polyfills/shims/process'`. When the
* consuming module (e.g. recoil) is hoisted to the monorepo root, Vite 7's ESM resolver walks up
* consuming module (e.g. recoil) is hoisted to the monorepo root, Vite's ESM resolver walks up
* from there and never finds the shims (installed only in client/node_modules). This map resolves
* the shim specifiers to absolute paths via CJS require.resolve anchored to the client directory.
*/
@ -84,7 +84,8 @@ export default defineConfig(({ command }) => ({
],
globIgnores: ['images/**/*', '**/*.map', 'index.html', 'assets/rum.*.js'],
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
/** LibreChat mutates index.html per request for subpath and language support. */
navigateFallback: null,
},
includeAssets: [],
manifest: {
@ -133,164 +134,186 @@ export default defineConfig(({ command }) => ({
sourcemap: process.env.NODE_ENV === 'development',
outDir: './dist',
minify: 'terser',
rollupOptions: {
rolldownOptions: {
preserveEntrySignatures: 'strict',
output: {
manualChunks(id: string) {
const normalizedId = id.replace(/\\/g, '/');
if (normalizedId.includes('node_modules')) {
if (normalizedId.includes('@hyperdx/')) {
return 'rum';
}
codeSplitting: {
groups: [
{
name(id: string) {
const normalizedId = id.replace(/\\/g, '/');
if (normalizedId.includes('node_modules')) {
if (normalizedId.includes('/node_modules/regenerator-runtime/')) {
return 'polyfills';
}
// IMPORTANT: mermaid and ALL its dependencies must be in the same chunk
// to avoid initialization order issues. This includes chevrotain, langium,
// dagre-d3-es, and their nested lodash-es dependencies.
if (
normalizedId.includes('mermaid') ||
normalizedId.includes('dagre-d3-es') ||
normalizedId.includes('chevrotain') ||
normalizedId.includes('langium') ||
normalizedId.includes('lodash-es')
) {
return 'mermaid';
}
if (normalizedId.includes('@hyperdx/')) {
return 'rum';
}
if (normalizedId.includes('@codesandbox/sandpack')) {
return 'sandpack';
}
if (normalizedId.includes('react-vtree')) {
return 'react-vtree';
}
if (normalizedId.includes('react-virtualized')) {
return 'virtualization';
}
if (normalizedId.includes('i18next') || normalizedId.includes('react-i18next')) {
return 'i18n';
}
// Only regular lodash (not lodash-es which goes to mermaid chunk)
if (normalizedId.includes('/lodash/')) {
return 'utilities';
}
if (normalizedId.includes('date-fns')) {
return 'date-utils';
}
if (normalizedId.includes('@dicebear')) {
return 'avatars';
}
if (
normalizedId.includes('react-dnd') ||
normalizedId.includes('dnd-core') ||
normalizedId.includes('react-flip-toolkit') ||
normalizedId.includes('flip-toolkit')
) {
return 'react-interactions';
}
if (normalizedId.includes('react-hook-form')) {
return 'forms';
}
if (normalizedId.includes('react-router-dom')) {
return 'routing';
}
if (
normalizedId.includes('qrcode.react') ||
normalizedId.includes('@marsidev/react-turnstile')
) {
return 'security-ui';
}
// IMPORTANT: mermaid and ALL its dependencies must be in the same chunk
// to avoid initialization order issues. This includes chevrotain, langium,
// dagre-d3-es, and their nested lodash-es dependencies.
if (
normalizedId.includes('mermaid') ||
normalizedId.includes('dagre-d3-es') ||
normalizedId.includes('chevrotain') ||
normalizedId.includes('langium') ||
normalizedId.includes('lodash-es')
) {
return 'mermaid';
}
if (normalizedId.includes('@codemirror/view')) {
return 'codemirror-view';
}
if (normalizedId.includes('@codemirror/state')) {
return 'codemirror-state';
}
if (normalizedId.includes('@codemirror/language')) {
return 'codemirror-language';
}
if (normalizedId.includes('@codemirror')) {
return 'codemirror-core';
}
if (normalizedId.includes('@codesandbox/sandpack')) {
return 'sandpack';
}
if (normalizedId.includes('react-vtree')) {
return 'react-vtree';
}
if (normalizedId.includes('react-virtualized')) {
return 'virtualization';
}
if (normalizedId.includes('i18next') || normalizedId.includes('react-i18next')) {
return 'i18n';
}
// Only regular lodash (not lodash-es which goes to mermaid chunk)
if (normalizedId.includes('/lodash/')) {
return 'utilities';
}
if (normalizedId.includes('date-fns')) {
return 'date-utils';
}
if (normalizedId.includes('@dicebear')) {
return 'avatars';
}
if (
normalizedId.includes('react-dnd') ||
normalizedId.includes('dnd-core') ||
normalizedId.includes('react-flip-toolkit') ||
normalizedId.includes('flip-toolkit')
) {
return 'react-interactions';
}
if (normalizedId.includes('react-hook-form')) {
return 'forms';
}
if (normalizedId.includes('react-router-dom')) {
return 'routing';
}
if (
normalizedId.includes('qrcode.react') ||
normalizedId.includes('@marsidev/react-turnstile')
) {
return 'security-ui';
}
if (
normalizedId.includes('react-markdown') ||
normalizedId.includes('remark-') ||
normalizedId.includes('rehype-')
) {
return 'markdown-processing';
}
if (normalizedId.includes('monaco-editor') || normalizedId.includes('@monaco-editor')) {
return 'code-editor';
}
if (normalizedId.includes('react-window') || normalizedId.includes('react-virtual')) {
return 'virtualization';
}
if (
normalizedId.includes('zod') ||
normalizedId.includes('yup') ||
normalizedId.includes('joi')
) {
return 'validation';
}
if (
normalizedId.includes('axios') ||
normalizedId.includes('ky') ||
normalizedId.includes('fetch')
) {
return 'http-client';
}
if (
normalizedId.includes('react-spring') ||
normalizedId.includes('react-transition-group')
) {
return 'animations';
}
if (normalizedId.includes('react-select') || normalizedId.includes('downshift')) {
return 'advanced-inputs';
}
if (normalizedId.includes('heic-to')) {
return 'heic-converter';
}
if (normalizedId.includes('@codemirror/view')) {
return 'codemirror-view';
}
if (normalizedId.includes('@codemirror/state')) {
return 'codemirror-state';
}
if (normalizedId.includes('@codemirror/language')) {
return 'codemirror-language';
}
if (normalizedId.includes('@codemirror')) {
return 'codemirror-core';
}
// Existing chunks
if (normalizedId.includes('@radix-ui')) {
return 'radix-ui';
}
if (normalizedId.includes('framer-motion')) {
return 'framer-motion';
}
if (
normalizedId.includes('node_modules/highlight.js') ||
normalizedId.includes('node_modules/lowlight')
) {
return 'markdown_highlight';
}
if (normalizedId.includes('katex') || normalizedId.includes('node_modules/katex')) {
return 'math-katex';
}
if (normalizedId.includes('node_modules/hast-util-raw')) {
return 'markdown_large';
}
if (normalizedId.includes('@tanstack')) {
return 'tanstack-vendor';
}
if (normalizedId.includes('@headlessui')) {
return 'headlessui';
}
if (
normalizedId.includes('react-markdown') ||
normalizedId.includes('remark-') ||
normalizedId.includes('rehype-')
) {
return 'markdown-processing';
}
if (
normalizedId.includes('monaco-editor') ||
normalizedId.includes('@monaco-editor')
) {
return 'code-editor';
}
if (
normalizedId.includes('react-window') ||
normalizedId.includes('react-virtual')
) {
return 'virtualization';
}
if (
normalizedId.includes('zod') ||
normalizedId.includes('yup') ||
normalizedId.includes('joi')
) {
return 'validation';
}
if (
normalizedId.includes('axios') ||
normalizedId.includes('ky') ||
normalizedId.includes('fetch')
) {
return 'http-client';
}
if (
normalizedId.includes('react-spring') ||
normalizedId.includes('react-transition-group')
) {
return 'animations';
}
if (normalizedId.includes('react-select') || normalizedId.includes('downshift')) {
return 'advanced-inputs';
}
if (normalizedId.includes('heic-to')) {
return 'heic-converter';
}
if (normalizedId.includes('@icons-pack/react-simple-icons/icons/')) {
return;
}
// Existing chunks
if (normalizedId.includes('@radix-ui')) {
return 'radix-ui';
}
if (normalizedId.includes('framer-motion')) {
return 'framer-motion';
}
if (
normalizedId.includes('node_modules/highlight.js') ||
normalizedId.includes('node_modules/lowlight')
) {
return 'markdown_highlight';
}
if (
normalizedId.includes('katex') ||
normalizedId.includes('node_modules/katex')
) {
return 'math-katex';
}
if (normalizedId.includes('node_modules/hast-util-raw')) {
return 'markdown_large';
}
if (normalizedId.includes('@tanstack')) {
return 'tanstack-vendor';
}
if (normalizedId.includes('@headlessui')) {
return 'headlessui';
}
// Everything else falls into a generic vendor chunk.
return 'vendor';
}
// Create a separate chunk for all locale files under src/locales.
if (normalizedId.includes('/src/locales/')) {
return 'locales';
}
// Let Rollup decide automatically for any other files.
return null;
if (normalizedId.includes('@icons-pack/react-simple-icons/icons/')) {
return null;
}
// Everything else falls into a generic vendor chunk.
return 'vendor';
}
if (normalizedId.includes('/src/polyfills/')) {
return 'polyfills';
}
// Create a separate chunk for all locale files under src/locales.
if (normalizedId.includes('/src/locales/')) {
return 'locales';
}
// Let Rolldown decide automatically for any other files.
return null;
},
},
],
},
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',

2145
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
"name": "LibreChat",
"version": "v0.8.6",
"description": "",
"packageManager": "npm@11.10.0",
"packageManager": "npm@11.13.0",
"workspaces": [
"api",
"client",

View file

@ -65,7 +65,7 @@
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.0",
"@types/multer": "^1.4.13",
"@types/node": "^20.3.0",
"@types/node": "^24.12.4",
"@types/node-fetch": "^2.6.13",
"@types/react": "^18.2.18",
"@types/sanitize-html": "^2.13.0",

View file

@ -15,6 +15,7 @@ import type {
AgentToolResources,
AgentToolOptions,
TEndpointOption,
ReasoningResponseKey,
TFile,
Agent,
TUser,
@ -173,6 +174,8 @@ export type InitializedAgent = Agent & {
actionsEnabled?: boolean;
/** Maximum characters allowed in a single tool result before truncation. */
maxToolResultChars?: number;
/** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */
reasoningKey?: ReasoningResponseKey;
/**
* Whether the code-execution environment is available *for this agent*.
* Narrower than the incoming `params.codeEnvAvailable` admin flag this
@ -1062,6 +1065,7 @@ export async function initializeAgent(
codeEnvAvailable: effectiveCodeEnvAvailable,
skillAuthoringAvailable,
fileAuthoringToolNames: fileAuthoringToolNames.size > 0 ? fileAuthoringToolNames : undefined,
reasoningKey: customEndpointConfig?.customParams?.reasoningKey,
skillCount,
accessibleSkillIds: executableSkillIds,
activeSkillNames,

View file

@ -1,5 +1,6 @@
import { Providers } from '@librechat/agents';
import { ToolMessage, AIMessage, HumanMessage } from '@librechat/agents/langchain/messages';
import { ReasoningResponseKey } from 'librechat-data-provider';
import {
extractDiscoveredToolsFromHistory,
@ -150,6 +151,56 @@ describe('getReasoningKey', () => {
expect(reasoningKey).toBe('reasoning');
});
it('keeps Vercel AI Gateway on ChatOpenAI normalized reasoning_content', () => {
const llmConfig = {
configuration: {
baseURL: 'https://ai-gateway.vercel.sh/v1',
},
} as Parameters<typeof getReasoningKey>[1];
const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig);
expect(reasoningKey).toBe('reasoning_content');
});
it('keeps Vercel custom endpoint names on ChatOpenAI normalized reasoning_content', () => {
const llmConfig = {} as Parameters<typeof getReasoningKey>[1];
const reasoningKey = getReasoningKey(Providers.OPENAI, llmConfig, 'Vercel');
expect(reasoningKey).toBe('reasoning_content');
});
it('uses explicit reasoning response keys for Vercel when configured', () => {
const llmConfig = {
configuration: {
baseURL: 'https://ai-gateway.vercel.sh/v1',
},
} as Parameters<typeof getReasoningKey>[1];
const reasoningKey = getReasoningKey(
Providers.OPENAI,
llmConfig,
'Vercel',
ReasoningResponseKey.reasoning,
);
expect(reasoningKey).toBe('reasoning');
});
it('uses explicit reasoning response keys for otherwise default OpenAI-compatible endpoints', () => {
const llmConfig = {} as Parameters<typeof getReasoningKey>[1];
const reasoningKey = getReasoningKey(
Providers.OPENAI,
llmConfig,
'Company Gateway',
ReasoningResponseKey.reasoning,
);
expect(reasoningKey).toBe('reasoning');
});
});
describe('isDeepSeekReasoningProvider', () => {

View file

@ -26,6 +26,7 @@ import type {
Agent,
AgentModelParameters,
AgentSubagentsConfig,
ReasoningResponseKey,
SummarizationConfig,
} from 'librechat-data-provider';
import type { BaseMessage } from '@librechat/agents/langchain/messages';
@ -212,6 +213,8 @@ const customProviders = new Set([
KnownEndpoints.ollama,
]);
type AgentReasoningKey = 'reasoning_content' | 'reasoning';
function includesOpenRouter(value?: string | null): boolean {
return typeof value === 'string' && value.toLowerCase().includes(KnownEndpoints.openrouter);
}
@ -220,8 +223,13 @@ export function getReasoningKey(
provider: Providers,
llmConfig: t.RunLLMConfig,
agentEndpoint?: string | null,
): 'reasoning_content' | 'reasoning' {
let reasoningKey: 'reasoning_content' | 'reasoning' = 'reasoning_content';
customReasoningKey?: ReasoningResponseKey,
): AgentReasoningKey {
if (customReasoningKey) {
return customReasoningKey as AgentReasoningKey;
}
let reasoningKey: AgentReasoningKey = 'reasoning_content';
if (provider === Providers.GOOGLE) {
reasoningKey = 'reasoning';
} else if (
@ -293,6 +301,8 @@ type RunAgent = Omit<Agent, 'tools'> & {
codeEnvAvailable?: boolean;
/** Optional per-agent summarization overrides */
summarization?: SummarizationConfig;
/** Response field to read model reasoning from for custom OpenAI-compatible endpoints. */
reasoningKey?: ReasoningResponseKey;
/**
* Maximum characters allowed in a single tool result before truncation.
* Overrides the default computed from maxContextTokens.
@ -946,7 +956,7 @@ export async function createRun({
agent.maxContextTokens,
);
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint);
const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint, agent.reasoningKey);
return {
provider,
reasoningKey,

View file

@ -327,6 +327,51 @@ describe('createAppConfigService', () => {
expect(deps.getApplicableConfigs).toHaveBeenCalledWith([]);
});
it('scopes the override cache key to the ALS tenant when no tenantId param is given', async () => {
const { tenantStorage } = jest.requireActual('@librechat/data-schemas');
const deps = createDeps({
getApplicableConfigs: jest
.fn()
.mockResolvedValue([{ priority: 10, overrides: { x: 1 }, isActive: true }]),
});
const { getAppConfig } = createAppConfigService(deps);
await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
getAppConfig({ role: 'USER' }),
);
const overrideKey = [...deps._cache._store.keys()].find((k: string) =>
k.includes('_OVERRIDE_:'),
);
expect(overrideKey).toBe('app_config:_OVERRIDE_:tenant-a:USER');
expect(overrideKey).not.toContain('__default__');
});
it('does not serve one tenant a cached config built for another tenant', async () => {
const { tenantStorage, getTenantId } = jest.requireActual('@librechat/data-schemas');
// Each tenant's DB overrides carry a marker derived from the active ALS tenant.
const deps = createDeps({
getApplicableConfigs: jest
.fn()
.mockImplementation(async () => [
{ priority: 10, overrides: { whoami: getTenantId() }, isActive: true },
]),
});
const { getAppConfig } = createAppConfigService(deps);
const configA = (await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
getAppConfig({ role: 'USER' }),
)) as TestConfig & { whoami?: string };
const configB = (await tenantStorage.run({ tenantId: 'tenant-b' }, async () =>
getAppConfig({ role: 'USER' }),
)) as TestConfig & { whoami?: string };
expect(configA.whoami).toBe('tenant-a');
expect(configB.whoami).toBe('tenant-b');
// A cache collision would short-circuit the second tenant's DB read.
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(2);
});
});
it('does not cache on buildPrincipals error — retries on next request', async () => {

View file

@ -73,7 +73,10 @@ export function _resetOverrideStrictCache(): void {
}
function overrideCacheKey(role?: string, userId?: string, tenantId?: string): string {
const tenant = tenantId || '__default__';
// Fall back to the ALS tenant context before `__default__`: callers that rely on the
// tenant middleware (the common path) pass no explicit tenantId, so without this the
// entry is keyed under the shared `__default__` bucket and leaks across tenants.
const tenant = tenantId || getTenantId() || '__default__';
if (userId && role) {
return `_OVERRIDE_:${tenant}:${role}:${userId}`;
}
@ -174,16 +177,16 @@ export function createAppConfigService(deps: AppConfigServiceDeps) {
return baseConfig;
}
// Strict-isolation + no tenant (param or ALS) = pathological path (middleware bypass or
// unauthenticated startup). Pre-tenant calls use baseOnly:true; admin calls carry tenantId.
// If ALS has a tenant, Mongoose scopes queries to that tenant's overrides — must fall through.
// Not cached: the cache key doesn't include ALS context, so a cached __default__ entry would
// be served to later ALS-scoped calls that share the same param-derived key.
// Strict isolation + no tenant anywhere (neither param nor ALS) is pathological: a
// middleware bypass or an unauthenticated startup call. Pre-tenant calls should use
// baseOnly:true and admin calls carry an explicit tenantId. Return the base config
// without caching it under the shared `__default__` bucket. When ALS has a tenant,
// overrideCacheKey scopes the key to it, so we fall through and cache per-tenant.
if (principals.length === 0 && !tenantId && !getTenantId() && isStrictOverrideMode()) {
return baseConfig;
}
if (!tenantId && isStrictOverrideMode() && !_warnedNoTenantInStrictMode) {
if (!tenantId && !getTenantId() && isStrictOverrideMode() && !_warnedNoTenantInStrictMode) {
_warnedNoTenantInStrictMode = true;
logger.warn(
'[getAppConfig] No tenantId in strict mode — falling back to __default__. ' +

View file

@ -10,7 +10,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => {
describe('OpenAI endpoint', () => {
it('should handle GPT-5 model with reasoning and web search', () => {
const apiKey = 'sk-proj-somekey';
const endpoint = undefined;
const endpoint = EModelEndpoint.openAI;
const options = {
modelOptions: {
model: 'gpt-5-nano',
@ -138,7 +138,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => {
it('should handle Azure OpenAI with Responses API and reasoning', () => {
const apiKey = 'some_azure_key';
const endpoint = undefined;
const endpoint = EModelEndpoint.azureOpenAI;
const options = {
modelOptions: {
model: 'gpt-5',
@ -395,6 +395,7 @@ describe('getOpenAIConfig - Backward Compatibility', () => {
modelOptions: {
model: '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b',
user: 'some-user',
reasoning_effort: ReasoningEffort.high,
},
reverseProxyUrl:
'https://gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/${CF_GATEWAY_ID}/workers-ai/v1',
@ -419,6 +420,9 @@ describe('getOpenAIConfig - Backward Compatibility', () => {
user: 'some-user',
disableStreaming: true,
apiKey: 'someKey',
modelKwargs: {
reasoning_effort: ReasoningEffort.high,
},
},
configOptions: {
baseURL:

View file

@ -3,6 +3,7 @@ import {
EModelEndpoint,
ReasoningEffort,
ReasoningSummary,
ReasoningParameterFormat,
} from 'librechat-data-provider';
import type { RequestInit } from 'undici';
import type { OpenAIParameters, AzureOptions } from '~/types';
@ -79,7 +80,7 @@ describe('getOpenAIConfig', () => {
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should handle reasoning params for `useResponsesApi`', () => {
it('should pass custom endpoint reasoning object through modelKwargs for `useResponsesApi`', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
@ -89,27 +90,29 @@ describe('getOpenAIConfig', () => {
modelOptions: { ...modelOptions, useResponsesApi: true },
});
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
expect(result.llmConfig.reasoning).toBeUndefined();
expect(result.llmConfig.modelKwargs).toEqual({
reasoning: {
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
},
});
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_summary).toBeUndefined();
});
it('should handle reasoning params without `useResponsesApi`', () => {
it('should pass custom endpoint reasoning through modelKwargs without `useResponsesApi`', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
};
const result = getOpenAIConfig(mockApiKey, { modelOptions });
const result = getOpenAIConfig(mockApiKey, { modelOptions }, 'custom-endpoint');
/** When no endpoint is specified, it's treated as non-openAI/azureOpenAI, so uses reasoning object */
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
expect(result.llmConfig.modelKwargs).toEqual({
reasoning_effort: ReasoningEffort.high,
});
expect(result.llmConfig.reasoning).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
@ -173,7 +176,7 @@ describe('getOpenAIConfig', () => {
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
it('should use reasoning object for non-openAI/azureOpenAI endpoints', () => {
it('should pass reasoning_effort through modelKwargs for non-openAI/azureOpenAI endpoints', () => {
const modelOptions = {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
@ -181,13 +184,102 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, { modelOptions }, 'custom-endpoint');
expect(result.llmConfig.reasoning).toEqual({
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
expect(result.llmConfig.modelKwargs).toEqual({
reasoning_effort: ReasoningEffort.high,
});
expect(result.llmConfig.reasoning).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
it('should support custom endpoint reasoning object format', () => {
const result = getOpenAIConfig(
mockApiKey,
{
customParams: {
reasoningFormat: ReasoningParameterFormat.reasoningObject,
},
modelOptions: {
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.detailed,
},
},
'custom-endpoint',
);
expect(result.llmConfig.modelKwargs).toEqual({
reasoning: {
effort: ReasoningEffort.high,
summary: ReasoningSummary.detailed,
},
});
expect(result.llmConfig.reasoning).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
it('should default Vercel custom endpoints to reasoning object format', () => {
const result = getOpenAIConfig(
mockApiKey,
{
reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1',
modelOptions: {
reasoning_effort: ReasoningEffort.high,
},
},
'Vercel',
);
expect(result.llmConfig.modelKwargs).toEqual({
reasoning: {
effort: ReasoningEffort.high,
},
});
expect(result.llmConfig.reasoning).toBeUndefined();
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
it('should apply Vercel reasoning format to custom default params', () => {
const result = getOpenAIConfig(
mockApiKey,
{
reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1',
customParams: {
paramDefinitions: [{ key: 'reasoning_effort', default: ReasoningEffort.low }],
},
modelOptions: {
model: 'openai/gpt-5-mini',
},
},
'Vercel',
);
expect(result.llmConfig.modelKwargs).toEqual({
reasoning: {
effort: ReasoningEffort.low,
},
});
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
});
it('should allow Vercel reasoning format override', () => {
const result = getOpenAIConfig(
mockApiKey,
{
reverseProxyUrl: 'https://ai-gateway.vercel.sh/v1',
customParams: {
reasoningFormat: ReasoningParameterFormat.reasoningEffort,
},
modelOptions: {
reasoning_effort: ReasoningEffort.high,
},
},
'Vercel',
);
expect(result.llmConfig.modelKwargs).toEqual({
reasoning_effort: ReasoningEffort.high,
});
});
it('should handle OpenRouter configuration', () => {
const reverseProxyUrl = 'https://openrouter.ai/api/v1';
@ -1006,11 +1098,12 @@ describe('getOpenAIConfig', () => {
const result = getOpenAIConfig(mockApiKey, {
modelOptions: { ...modelOptions, useResponsesApi: true } as Partial<OpenAIParameters>,
});
const reasoning = result.llmConfig?.reasoning ?? result.llmConfig?.modelKwargs?.reasoning;
if (shouldHaveReasoning) {
expect(result.llmConfig?.reasoning).toBeDefined();
expect(reasoning).toBeDefined();
} else {
expect(result.llmConfig?.reasoning).toBeUndefined();
expect(reasoning).toBeUndefined();
}
});
});
@ -1088,6 +1181,7 @@ describe('getOpenAIConfig', () => {
frequency_penalty: 0.5,
presence_penalty: 0.6,
max_tokens: 1000,
reasoning_effort: ReasoningEffort.high,
custom_param: 'should-remain',
};
@ -1102,6 +1196,7 @@ describe('getOpenAIConfig', () => {
/** `presence_penalty` is converted to `presencePenalty` */
expect(result.llmConfig.maxTokens).toBe(1000); // max_tokens is allowed
expect((result.llmConfig as Record<string, unknown>).custom_param).toBe('should-remain');
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
});
@ -1209,9 +1304,11 @@ describe('getOpenAIConfig', () => {
streaming: false,
useResponsesApi: true, // From web_search
});
expect(result.llmConfig.reasoning).toBeUndefined();
expect(result.llmConfig.maxTokens).toBe(2000);
expect(result.llmConfig.modelKwargs).toEqual({
text: { verbosity: Verbosity.medium },
reasoning: { effort: ReasoningEffort.high },
customParam: 'custom-value',
});
expect(result.tools).toEqual([{ type: 'web_search' }]);

View file

@ -1,6 +1,6 @@
import { ProxyAgent } from 'undici';
import { Providers } from '@librechat/agents';
import { KnownEndpoints, EModelEndpoint } from 'librechat-data-provider';
import { KnownEndpoints, EModelEndpoint, ReasoningParameterFormat } from 'librechat-data-provider';
import type * as t from '~/types';
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
import { getOpenAILLMConfig, extractDefaultParams } from './llm';
@ -34,6 +34,22 @@ function getDefaultParams({
};
}
function getReasoningFormat({
customFormat,
isVercel,
}: {
customFormat?: ReasoningParameterFormat;
isVercel: boolean;
}): ReasoningParameterFormat | undefined {
if (customFormat) {
return customFormat;
}
if (isVercel) {
return ReasoningParameterFormat.reasoningObject;
}
return undefined;
}
function mergeHeadersPreservingAnthropicBeta(
headers: Record<string, string> | undefined,
defaultHeaders: Record<string, string>,
@ -159,6 +175,10 @@ export function getOpenAIConfig(
defaultParams,
modelOptions,
useOpenRouter,
reasoningFormat: getReasoningFormat({
customFormat: options.customParams?.reasoningFormat,
isVercel: Boolean(isVercel),
}),
});
llmConfig = openaiResult.llmConfig;
azure = openaiResult.azure;

View file

@ -3,6 +3,7 @@ import {
EModelEndpoint,
ReasoningEffort,
ReasoningSummary,
ReasoningParameterFormat,
} from 'librechat-data-provider';
import { getOpenAILLMConfig, extractDefaultParams, applyDefaultParams } from './llm';
import type * as t from '~/types';
@ -463,23 +464,159 @@ describe('getOpenAILLMConfig', () => {
expect(result.llmConfig).toHaveProperty('reasoning_effort', ReasoningEffort.high);
});
it('should use reasoning object for non-OpenAI endpoints', () => {
it('should pass reasoning_effort through modelKwargs for custom endpoints', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
modelOptions: {
model: 'o1',
model: 'provider/reasoning-model',
reasoning_effort: ReasoningEffort.high,
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning_effort', ReasoningEffort.high);
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig).not.toHaveProperty('reasoning_effort');
});
it('should support reasoning object passthrough for custom endpoints', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.reasoningObject,
modelOptions: {
model: 'provider/reasoning-model',
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.concise,
},
});
expect(result.llmConfig).toHaveProperty('reasoning');
expect(result.llmConfig.reasoning).toEqual({
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', {
effort: ReasoningEffort.high,
summary: ReasoningSummary.concise,
});
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig).not.toHaveProperty('reasoning_effort');
});
it('should apply reasoning format to default reasoning params', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.reasoningObject,
defaultParams: {
reasoning_effort: ReasoningEffort.low,
reasoning_summary: ReasoningSummary.concise,
},
modelOptions: {
model: 'provider/reasoning-model',
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', {
effort: ReasoningEffort.low,
summary: ReasoningSummary.concise,
});
expect(result.llmConfig.modelKwargs).not.toHaveProperty('reasoning_effort');
});
it('should let addParams reasoning override default reasoning params before formatting', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.reasoningObject,
defaultParams: {
reasoning_effort: ReasoningEffort.low,
},
addParams: {
reasoning_effort: ReasoningEffort.high,
},
modelOptions: {
model: 'provider/reasoning-model',
},
});
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', {
effort: ReasoningEffort.high,
});
expect(result.llmConfig.modelKwargs).not.toHaveProperty('reasoning_effort');
});
it('should allow custom endpoints to disable reasoning passthrough', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.disabled,
modelOptions: {
model: 'provider/reasoning-model',
reasoning_effort: ReasoningEffort.high,
},
});
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig).not.toHaveProperty('reasoning_effort');
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should use Responses API reasoning when web_search enables Responses API', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
modelOptions: {
model: 'provider/reasoning-model',
reasoning_effort: ReasoningEffort.high,
reasoning_summary: ReasoningSummary.concise,
web_search: true,
},
});
expect(result.llmConfig).toHaveProperty('useResponsesApi', true);
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', {
effort: ReasoningEffort.high,
summary: ReasoningSummary.concise,
});
expect(result.tools).toContainEqual({ type: 'web_search' });
});
it('should remove reasoning kwargs for GPT-4o search models', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
modelOptions: {
model: 'gpt-4o-search',
reasoning_effort: ReasoningEffort.high,
},
});
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig).not.toHaveProperty('reasoning_effort');
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should honor dropParams after reasoning object conversion', () => {
const result = getOpenAILLMConfig({
apiKey: 'test-api-key',
streaming: true,
endpoint: 'custom',
reasoningFormat: ReasoningParameterFormat.reasoningObject,
dropParams: ['reasoning_effort'],
modelOptions: {
model: 'provider/reasoning-model',
reasoning_effort: ReasoningEffort.high,
},
});
expect(result.llmConfig).not.toHaveProperty('reasoning');
expect(result.llmConfig).not.toHaveProperty('reasoning_effort');
expect(result.llmConfig.modelKwargs).toBeUndefined();
});
it('should use reasoning object when useResponsesApi is true', () => {

View file

@ -1,5 +1,6 @@
import {
EModelEndpoint,
ReasoningParameterFormat,
removeNullishValues,
supportsAdaptiveThinking,
} from 'librechat-data-provider';
@ -86,6 +87,78 @@ function hasReasoningParams({
);
}
function getReasoningObject({
reasoningEffort,
reasoningSummary,
}: {
reasoningEffort?: OpenAILLMConfig['reasoning_effort'];
reasoningSummary?: OpenAILLMConfig['reasoning_summary'];
}): OpenAI.Reasoning {
return removeNullishValues(
{
effort: reasoningEffort,
summary: reasoningSummary,
},
true,
) as OpenAI.Reasoning;
}
function isOpenAIEndpoint(endpoint?: EModelEndpoint | string | null): boolean {
return endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI;
}
function removeReasoningSummary(target: Record<string, unknown>) {
const { reasoning } = target;
if (reasoning == null || typeof reasoning !== 'object' || Array.isArray(reasoning)) {
return;
}
const rest = { ...(reasoning as Record<string, unknown>) };
delete rest.summary;
if (Object.keys(rest).length === 0) {
delete target.reasoning;
return;
}
target.reasoning = rest;
}
function removeReasoningPayload(target: Record<string, unknown>) {
delete target.reasoning;
delete target.reasoning_effort;
}
function deleteConfigParam({
param,
llmConfig,
modelKwargs,
}: {
param: string;
llmConfig: OpenAILLMConfig;
modelKwargs: Record<string, unknown>;
}) {
if (param === 'reasoning_effort') {
removeReasoningPayload(llmConfig as Record<string, unknown>);
removeReasoningPayload(modelKwargs);
return;
}
if (param === 'reasoning_summary') {
delete (llmConfig as Record<string, unknown>).reasoning_summary;
delete modelKwargs.reasoning_summary;
removeReasoningSummary(llmConfig as Record<string, unknown>);
removeReasoningSummary(modelKwargs);
return;
}
if (param in llmConfig) {
delete llmConfig[param as keyof t.OAIClientOptions];
}
if (param in modelKwargs) {
delete modelKwargs[param];
}
}
const openRouterAnthropicVerbosityByEffort: Record<
string,
NonNullable<OpenAILLMConfig['verbosity']>
@ -204,6 +277,64 @@ function applyOpenRouterReasoningConfig({
return true;
}
function applyReasoningConfig({
endpoint,
llmConfig,
modelKwargs,
reasoningEffort,
reasoningFormat,
reasoningSummary,
}: {
endpoint?: EModelEndpoint | string | null;
llmConfig: OpenAILLMConfig;
modelKwargs: Record<string, unknown>;
reasoningEffort?: OpenAILLMConfig['reasoning_effort'];
reasoningFormat?: ReasoningParameterFormat;
reasoningSummary?: OpenAILLMConfig['reasoning_summary'];
}): boolean {
if (
!hasReasoningParams({
reasoning_effort: reasoningEffort,
reasoning_summary: reasoningSummary,
})
) {
return false;
}
const reasoning = getReasoningObject({ reasoningEffort, reasoningSummary });
if (reasoningFormat === ReasoningParameterFormat.disabled) {
return false;
}
if (isOpenAIEndpoint(endpoint)) {
if (llmConfig.useResponsesApi === true) {
llmConfig.reasoning = reasoning;
return false;
}
if (reasoningEffort) {
llmConfig.reasoning_effort = reasoningEffort;
}
return false;
}
if (llmConfig.useResponsesApi === true) {
modelKwargs.reasoning = reasoning;
return true;
}
if (reasoningFormat === ReasoningParameterFormat.reasoningObject) {
modelKwargs.reasoning = reasoning;
return true;
}
if (reasoningEffort) {
modelKwargs.reasoning_effort = reasoningEffort;
return true;
}
return false;
}
function getModelKwargsText(modelKwargs: Record<string, unknown>): Record<string, unknown> {
const { text } = modelKwargs;
if (text == null || typeof text !== 'object' || Array.isArray(text)) {
@ -294,6 +425,7 @@ export function getOpenAILLMConfig({
dropParams,
defaultParams,
useOpenRouter,
reasoningFormat = ReasoningParameterFormat.reasoningEffort,
modelOptions: _modelOptions,
}: {
apiKey: string;
@ -305,6 +437,7 @@ export function getOpenAILLMConfig({
dropParams?: string[];
defaultParams?: Record<string, unknown>;
useOpenRouter?: boolean;
reasoningFormat?: ReasoningParameterFormat;
azure?: false | t.AzureOptions;
}): Pick<t.LLMConfigResult, 'llmConfig' | 'tools'> & {
azure?: t.AzureOptions;
@ -343,6 +476,8 @@ export function getOpenAILLMConfig({
const modelKwargs: Record<string, unknown> = {};
let hasModelKwargs = false;
let reasoningEffort = reasoning_effort;
let reasoningSummary = reasoning_summary;
if (verbosity != null && verbosity !== '' && useOpenRouter) {
llmConfig.verbosity = verbosity;
@ -369,6 +504,18 @@ export function getOpenAILLMConfig({
}
continue;
}
if (key === 'reasoning_effort') {
if (!reasoningEffort && typeof value === 'string') {
reasoningEffort = value as OpenAILLMConfig['reasoning_effort'];
}
continue;
}
if (key === 'reasoning_summary') {
if (!reasoningSummary && typeof value === 'string') {
reasoningSummary = value as OpenAILLMConfig['reasoning_summary'];
}
continue;
}
if (key === 'verbosity') {
hasModelKwargs =
applyVerbosityParam({
@ -408,6 +555,18 @@ export function getOpenAILLMConfig({
}
continue;
}
if (key === 'reasoning_effort') {
if (typeof value === 'string' || value == null) {
reasoningEffort = value as OpenAILLMConfig['reasoning_effort'];
}
continue;
}
if (key === 'reasoning_summary') {
if (typeof value === 'string' || value == null) {
reasoningSummary = value as OpenAILLMConfig['reasoning_summary'];
}
continue;
}
if (key === 'verbosity') {
hasModelKwargs =
applyVerbosityParam({
@ -437,25 +596,11 @@ export function getOpenAILLMConfig({
*/
hasModelKwargs =
applyOpenRouterReasoningConfig({
reasoningEffort: reasoning_effort,
reasoningEffort,
model: modelOptions.model,
modelKwargs,
llmConfig,
}) || hasModelKwargs;
} else if (
hasReasoningParams({ reasoning_effort, reasoning_summary }) &&
(llmConfig.useResponsesApi === true ||
(endpoint !== EModelEndpoint.openAI && endpoint !== EModelEndpoint.azureOpenAI))
) {
llmConfig.reasoning = removeNullishValues(
{
effort: reasoning_effort,
summary: reasoning_summary,
},
true,
) as OpenAI.Reasoning;
} else if (hasReasoningParams({ reasoning_effort })) {
llmConfig.reasoning_effort = reasoning_effort;
}
if (llmConfig.max_tokens != null) {
@ -486,6 +631,18 @@ export function getOpenAILLMConfig({
llmConfig.promptCache = true;
}
if (!useOpenRouter) {
hasModelKwargs =
applyReasoningConfig({
endpoint,
llmConfig,
modelKwargs,
reasoningFormat,
reasoningEffort,
reasoningSummary,
}) || hasModelKwargs;
}
/** DeepSeek thinking-mode requires `reasoning_content` replay on tool turns (#13366). */
if (
typeof modelOptions.model === 'string' &&
@ -515,11 +672,7 @@ export function getOpenAILLMConfig({
const updatedDropParams = dropParams || [];
const combinedDropParams = [...new Set([...updatedDropParams, ...reasoningExcludeParams])];
combinedDropParams.forEach((param) => {
if (param in llmConfig) {
delete llmConfig[param as keyof t.OAIClientOptions];
}
});
combinedDropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs }));
} else if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model as string)) {
/**
* Note: OpenAI Web Search models do not support any known parameters besides `max_tokens`
@ -544,17 +697,9 @@ export function getOpenAILLMConfig({
const updatedDropParams = dropParams || [];
const combinedDropParams = [...new Set([...updatedDropParams, ...searchExcludeParams])];
combinedDropParams.forEach((param) => {
if (param in llmConfig) {
delete llmConfig[param as keyof t.OAIClientOptions];
}
});
combinedDropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs }));
} else if (dropParams && Array.isArray(dropParams)) {
dropParams.forEach((param) => {
if (param in llmConfig) {
delete llmConfig[param as keyof t.OAIClientOptions];
}
});
dropParams.forEach((param) => deleteConfigParam({ param, llmConfig, modelKwargs }));
}
hasModelKwargs =
@ -576,7 +721,7 @@ export function getOpenAILLMConfig({
hasModelKwargs = true;
}
if (hasModelKwargs) {
if (hasModelKwargs && Object.keys(modelKwargs).length > 0) {
llmConfig.modelKwargs = modelKwargs;
}

View file

@ -506,7 +506,8 @@ describe('telemetry SDK lifecycle', () => {
mockShutdown.mockRejectedValueOnce(new Error('flush failed'));
initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' });
const taskFn = (registerShutdownTask as jest.Mock).mock.calls.at(-1)?.[1] as
const shutdownTaskCalls = (registerShutdownTask as jest.Mock).mock.calls;
const taskFn = shutdownTaskCalls[shutdownTaskCalls.length - 1]?.[1] as
| (() => Promise<void>)
| undefined;
expect(taskFn).toBeDefined();

View file

@ -11,11 +11,32 @@ export interface SecretInputProps
onCopy?: () => void;
/** Duration in ms to show checkmark after copy (default: 2000) */
copyFeedbackDuration?: number;
label?: React.ReactNode;
labelClassName?: string;
containerClassName?: string;
controlsClassName?: string;
buttonClassName?: string;
controlsOnHover?: boolean;
}
const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
(
{ className, showCopy = false, onCopy, copyFeedbackDuration = 2000, disabled, value, ...props },
{
id,
label,
className,
showCopy = false,
labelClassName,
containerClassName,
controlsClassName,
buttonClassName,
controlsOnHover = false,
onCopy,
copyFeedbackDuration = 2000,
disabled,
value,
...props
},
ref,
) => {
const [isVisible, setIsVisible] = useState(false);
@ -49,13 +70,14 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
}, [value, isCopied, disabled, onCopy, copyFeedbackDuration]);
return (
<div className="relative flex items-center">
<div className={cn('group/secret-input relative', containerClassName)}>
<input
id={id}
type={isVisible ? 'text' : 'password'}
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
showCopy ? 'pr-20' : 'pr-10',
'flex h-10 w-full rounded-lg border border-border-light bg-transparent py-2 pl-3 text-sm transition-colors placeholder:text-muted-foreground hover:border-border-medium focus-visible:border-border-heavy focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
showCopy ? 'pr-20' : 'pr-11',
)}
ref={ref}
disabled={disabled}
@ -64,17 +86,30 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
spellCheck={false}
{...props}
/>
<div className="absolute right-1 flex items-center gap-0.5">
{label != null && (
<label htmlFor={id} className={cn(labelClassName ?? '')}>
{label}
</label>
)}
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-1.5 flex items-center gap-0.5 [&>button]:pointer-events-auto',
controlsOnHover &&
'opacity-0 transition-opacity duration-150 group-focus-within/secret-input:opacity-100 group-hover/secret-input:opacity-100',
controlsClassName,
)}
>
{showCopy && (
<button
type="button"
onClick={handleCopy}
disabled={disabled || !value}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary [&>svg]:block',
disabled || !value
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
buttonClassName,
)}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
>
@ -86,12 +121,13 @@ const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
onClick={toggleVisibility}
disabled={disabled}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
'inline-flex size-7 shrink-0 items-center justify-center rounded-md text-text-secondary transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary [&>svg]:block',
disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
buttonClassName,
)}
aria-label={isVisible ? 'Hide password' : 'Show password'}
aria-label={isVisible ? 'Hide secret' : 'Show secret'}
>
{isVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>

View file

@ -60,7 +60,7 @@
"@rollup/plugin-terser": "^1.0.0",
"@types/jest": "^29.5.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.3.0",
"@types/node": "^24.12.4",
"@types/react": "^18.2.18",
"@types/winston": "^2.4.4",
"jest": "^30.2.0",

View file

@ -10,7 +10,12 @@ import {
summarizationTriggerSchema,
summarizationConfigSchema,
} from '../src/config';
import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas';
import {
tModelSpecPresetSchema,
EModelEndpoint,
ReasoningParameterFormat,
ReasoningResponseKey,
} from '../src/schemas';
import { specsConfigSchema } from '../src/models';
import { FileSources } from '../src/types/files';
@ -305,6 +310,58 @@ describe('endpointSchema addParams validation', () => {
expect(result.success).toBe(true);
});
it('accepts custom reasoning format config', () => {
const result = endpointSchema.safeParse({
...validEndpoint,
customParams: {
reasoningFormat: ReasoningParameterFormat.reasoningObject,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customParams?.reasoningFormat).toBe(
ReasoningParameterFormat.reasoningObject,
);
}
});
it('rejects invalid custom reasoning format config', () => {
const result = endpointSchema.safeParse({
...validEndpoint,
customParams: {
reasoningFormat: 'provider_magic',
},
});
expect(result.success).toBe(false);
});
it('accepts custom reasoning response key config', () => {
const result = endpointSchema.safeParse({
...validEndpoint,
customParams: {
reasoningKey: ReasoningResponseKey.reasoning,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customParams?.reasoningKey).toBe(ReasoningResponseKey.reasoning);
}
});
it('rejects invalid custom reasoning response key config', () => {
const result = endpointSchema.safeParse({
...validEndpoint,
customParams: {
reasoningKey: 'reasoning_text',
},
});
expect(result.success).toBe(false);
});
it('rejects non-boolean web_search objects in addParams', () => {
const result = endpointSchema.safeParse({
...validEndpoint,

View file

@ -1,7 +1,13 @@
import { z } from 'zod';
import type { ZodError } from 'zod';
import type { TEndpointsConfig, TModelsConfig, TConfig } from './types';
import { EModelEndpoint, eModelEndpointSchema, isAgentsEndpoint } from './schemas';
import {
EModelEndpoint,
eModelEndpointSchema,
isAgentsEndpoint,
eReasoningParameterFormatSchema,
eReasoningResponseKeySchema,
} from './schemas';
import { ComponentTypes, SettingTypes, OptionTypes } from './generate';
import { specsConfigSchema, TSpecsConfig } from './models';
import { fileConfigSchema } from './file-config';
@ -630,6 +636,8 @@ export const endpointSchema = baseEndpointSchema.merge(
customParams: z
.object({
defaultParamsEndpoint: z.string().default('custom'),
reasoningFormat: eReasoningParameterFormatSchema.optional(),
reasoningKey: eReasoningResponseKeySchema.optional(),
paramDefinitions: z.array(paramDefinitionSchema).optional(),
})
.strict()

View file

@ -192,6 +192,12 @@ const BaseOptionsSchema = z.object({
z.object({
title: z.string(),
description: z.string(),
/**
* Whether the field holds a secret and should be masked in the UI.
* Defaults to masked when omitted; set to `false` for non-secret setup
* values (e.g. username, project key, base URL) to render as plain text.
*/
sensitive: z.boolean().optional(),
}),
)
.optional(),

View file

@ -177,6 +177,17 @@ export enum ReasoningEffort {
xhigh = 'xhigh',
}
export enum ReasoningParameterFormat {
disabled = 'disabled',
reasoningEffort = 'reasoning_effort',
reasoningObject = 'reasoning_object',
}
export enum ReasoningResponseKey {
reasoning = 'reasoning',
reasoningContent = 'reasoning_content',
}
export enum AnthropicEffort {
unset = '',
low = 'low',
@ -250,6 +261,8 @@ export const imageDetailValue = {
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort);
export const eReasoningParameterFormatSchema = z.nativeEnum(ReasoningParameterFormat);
export const eReasoningResponseKeySchema = z.nativeEnum(ReasoningResponseKey);
export const eAnthropicEffortSchema = z.nativeEnum(AnthropicEffort);
export const eThinkingDisplaySchema = z.nativeEnum(ThinkingDisplay);
export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary);
@ -629,6 +642,8 @@ export const tPluginAuthConfigSchema = z.object({
label: z.string(),
description: z.string(),
optional: z.boolean().optional(),
/** Whether the field holds a secret and should be masked in the UI (defaults to masked when omitted). */
sensitive: z.boolean().optional(),
});
export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;

View file

@ -7,6 +7,8 @@ import type {
TAttachment,
TMessage,
TBanner,
ReasoningResponseKey,
ReasoningParameterFormat,
} from './schemas';
import type { RefillIntervalUnit } from './balance';
import type { SettingDefinition } from './generate';
@ -398,6 +400,8 @@ export type TConfig = {
capabilities?: string[];
customParams?: {
defaultParamsEndpoint?: string;
reasoningFormat?: ReasoningParameterFormat;
reasoningKey?: ReasoningResponseKey;
paramDefinitions?: Partial<SettingDefinition>[];
};
};

View file

@ -52,7 +52,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.0",
"@types/node": "^24.12.4",
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"mongodb-memory-server": "^11.0.1",

View file

@ -0,0 +1,114 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { PrincipalType, PrincipalModel, PermissionBits } from 'librechat-data-provider';
import type { IAclEntry } from '..';
import { createAclEntryMethods } from './aclEntry';
import { createModels } from '../models';
import { tenantStorage } from '../config/tenantContext';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
const TENANT_A = 'tenant-aaaaaaaaaaaaaaaaaaaa';
const TENANT_B = 'tenant-bbbbbbbbbbbbbbbbbbbb';
const RESOURCE_TYPE = 'agent';
let mongoServer: MongoMemoryServer;
let AclEntry: mongoose.Model<IAclEntry>;
let methods: ReturnType<typeof createAclEntryMethods>;
function runAs<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return tenantStorage.run({ tenantId }, fn);
}
async function seedAcl(tenantId: string, doc: Record<string, unknown>): Promise<void> {
await runAs(tenantId, async () => {
await new AclEntry(doc).save();
});
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
AclEntry = mongoose.models.AclEntry as mongoose.Model<IAclEntry>;
methods = createAclEntryMethods(mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
});
describe('AclEntry resource lookups are scoped to the active tenant', () => {
it('findAccessibleResources returns only the current tenant resources', async () => {
const principalId = new mongoose.Types.ObjectId();
const resourceA = new mongoose.Types.ObjectId();
const resourceB = new mongoose.Types.ObjectId();
await seedAcl(TENANT_A, {
principalType: PrincipalType.USER,
principalModel: PrincipalModel.USER,
principalId,
resourceType: RESOURCE_TYPE,
resourceId: resourceA,
permBits: PermissionBits.VIEW,
});
await seedAcl(TENANT_B, {
principalType: PrincipalType.USER,
principalModel: PrincipalModel.USER,
principalId,
resourceType: RESOURCE_TYPE,
resourceId: resourceB,
permBits: PermissionBits.VIEW,
});
const principals = [{ principalType: PrincipalType.USER, principalId }];
const aResources = await runAs(TENANT_A, () =>
methods.findAccessibleResources(principals, RESOURCE_TYPE, PermissionBits.VIEW),
);
expect(aResources.map(String)).toEqual([String(resourceA)]);
const bResources = await runAs(TENANT_B, () =>
methods.findAccessibleResources(principals, RESOURCE_TYPE, PermissionBits.VIEW),
);
expect(bResources.map(String)).toEqual([String(resourceB)]);
});
it('findPublicResourceIds returns only the current tenant public resources', async () => {
const publicA = new mongoose.Types.ObjectId();
const publicB = new mongoose.Types.ObjectId();
await seedAcl(TENANT_A, {
principalType: PrincipalType.PUBLIC,
resourceType: RESOURCE_TYPE,
resourceId: publicA,
permBits: PermissionBits.VIEW,
});
await seedAcl(TENANT_B, {
principalType: PrincipalType.PUBLIC,
resourceType: RESOURCE_TYPE,
resourceId: publicB,
permBits: PermissionBits.VIEW,
});
const aPublic = await runAs(TENANT_A, () =>
methods.findPublicResourceIds(RESOURCE_TYPE, PermissionBits.VIEW),
);
expect(aPublic.map(String)).toEqual([String(publicA)]);
const bPublic = await runAs(TENANT_B, () =>
methods.findPublicResourceIds(RESOURCE_TYPE, PermissionBits.VIEW),
);
expect(bPublic.map(String)).toEqual([String(publicB)]);
});
});

View file

@ -0,0 +1,60 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type { IAgentCategory } from '..';
import { createAgentCategoryMethods } from './agentCategory';
import { createModels } from '../models';
import { tenantStorage } from '../config/tenantContext';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
const TENANT_A = 'tenant-aaaaaaaaaaaaaaaaaaaa';
const TENANT_B = 'tenant-bbbbbbbbbbbbbbbbbbbb';
let mongoServer: MongoMemoryServer;
let AgentCategory: mongoose.Model<IAgentCategory>;
let methods: ReturnType<typeof createAgentCategoryMethods>;
function runAs<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return tenantStorage.run({ tenantId }, fn);
}
async function seedCategory(tenantId: string, value: string): Promise<void> {
await runAs(tenantId, async () => {
await new AgentCategory({ value, label: value, isActive: true }).save();
});
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
AgentCategory = mongoose.models.AgentCategory as mongoose.Model<IAgentCategory>;
methods = createAgentCategoryMethods(mongoose);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AgentCategory.deleteMany({});
});
describe('getValidCategoryValues is scoped to the active tenant', () => {
it('returns only the current tenant category values', async () => {
await seedCategory(TENANT_A, 'alpha');
await seedCategory(TENANT_B, 'beta');
const aValues = await runAs(TENANT_A, () => methods.getValidCategoryValues());
expect(aValues).toEqual(['alpha']);
const bValues = await runAs(TENANT_B, () => methods.getValidCategoryValues());
expect(bValues).toEqual(['beta']);
});
});

View file

@ -0,0 +1,141 @@
import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { IRole } from '..';
import { createRoleMethods } from './role';
import { createModels } from '../models';
import { tenantStorage } from '../config/tenantContext';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
/**
* Real Map-backed cache. A jest.fn mock can only assert which key was passed;
* a real store reproduces the actual collision: when two tenants write under the
* same key, the second read serves the first tenant's value.
*/
function createMapCache() {
const store = new Map<string, unknown>();
return {
store,
get: async (k: string): Promise<unknown> => store.get(k),
set: async (k: string, v: unknown): Promise<void> => {
store.set(k, v);
},
del: async (k: string): Promise<void> => {
store.delete(k);
},
};
}
const TENANT_A = 'tenant-aaaaaaaaaaaaaaaaaaaa';
const TENANT_B = 'tenant-bbbbbbbbbbbbbbbbbbbb';
const ROLE_NAME = 'EDITOR';
let mongoServer: MongoMemoryServer;
let Role: mongoose.Model<IRole>;
let cache: ReturnType<typeof createMapCache>;
let getRoleByName: ReturnType<typeof createRoleMethods>['getRoleByName'];
function runAs<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
return tenantStorage.run({ tenantId }, fn);
}
function usePromptsPermission(role: IRole | null | undefined): boolean | undefined {
const permissions = (role as unknown as { permissions?: Record<string, Record<string, boolean>> })
?.permissions;
return permissions?.[PermissionTypes.PROMPTS]?.[Permissions.USE];
}
async function seedRole(tenantId: string, useValue: boolean): Promise<void> {
await runAs(tenantId, async () => {
await new Role({
name: ROLE_NAME,
permissions: { [PermissionTypes.PROMPTS]: { [Permissions.USE]: useValue } },
}).save();
});
}
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
createModels(mongoose);
Role = mongoose.models.Role as mongoose.Model<IRole>;
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Role.deleteMany({});
cache = createMapCache();
const methods = createRoleMethods(mongoose, { getCache: () => cache });
getRoleByName = methods.getRoleByName;
});
describe('getRoleByName cache is scoped to the active tenant', () => {
it('does not serve one tenant a cached role belonging to another tenant', async () => {
await seedRole(TENANT_A, true);
await seedRole(TENANT_B, false);
const roleA = await runAs(TENANT_A, () => getRoleByName(ROLE_NAME));
expect(usePromptsPermission(roleA)).toBe(true);
expect(cache.store.size).toBeGreaterThan(0);
const roleB = await runAs(TENANT_B, () => getRoleByName(ROLE_NAME));
expect(usePromptsPermission(roleB)).toBe(false);
const roleAAgain = await runAs(TENANT_A, () => getRoleByName(ROLE_NAME));
expect(usePromptsPermission(roleAAgain)).toBe(true);
});
it('appends the tenant id to the cache key', async () => {
await seedRole(TENANT_A, true);
await seedRole(TENANT_B, false);
await runAs(TENANT_A, () => getRoleByName(ROLE_NAME));
await runAs(TENANT_B, () => getRoleByName(ROLE_NAME));
expect(cache.store.has(`${ROLE_NAME}:${TENANT_A}`)).toBe(true);
expect(cache.store.has(`${ROLE_NAME}:${TENANT_B}`)).toBe(true);
expect(cache.store.has(ROLE_NAME)).toBe(false);
});
it('serves the cached value within the same tenant without a second DB read', async () => {
await seedRole(TENANT_A, true);
const first = await runAs(TENANT_A, () => getRoleByName(ROLE_NAME));
expect(usePromptsPermission(first)).toBe(true);
const findOneSpy = jest.spyOn(Role, 'findOne');
const second = await runAs(TENANT_A, () => getRoleByName(ROLE_NAME));
expect(usePromptsPermission(second)).toBe(true);
expect(findOneSpy).not.toHaveBeenCalled();
findOneSpy.mockRestore();
});
it('uses the unscoped key when no tenant context is active (single-tenant)', async () => {
const previousDefault = process.env.DEFAULT_TENANT_ID;
delete process.env.DEFAULT_TENANT_ID;
try {
await seedRole(TENANT_A, true);
const role = await getRoleByName(ROLE_NAME);
expect(usePromptsPermission(role)).toBe(true);
expect(cache.store.has(ROLE_NAME)).toBe(true);
expect(cache.store.has(`${ROLE_NAME}:${TENANT_A}`)).toBe(false);
} finally {
if (previousDefault === undefined) {
delete process.env.DEFAULT_TENANT_ID;
} else {
process.env.DEFAULT_TENANT_ID = previousDefault;
}
}
});
});

View file

@ -7,6 +7,7 @@ import {
} from 'librechat-data-provider';
import type { Model } from 'mongoose';
import type { IRole, IUser } from '~/types';
import { scopedCacheKey } from '~/config/tenantContext';
import logger from '~/config/winston';
const systemRoleValues = new Set<string>(Object.values(SystemRoles));
@ -94,7 +95,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
const cache = deps.getCache?.(CacheKeys.ROLES);
try {
if (cache) {
const cachedRole = await cache.get(roleName);
const cachedRole = await cache.get(scopedCacheKey(roleName));
if (cachedRole) {
return cachedRole as IRole;
}
@ -109,12 +110,12 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
if (!role && systemRoleValues.has(roleName)) {
const newRole = await new Role(roleDefaults[roleName as keyof typeof roleDefaults]).save();
if (cache) {
await cache.set(roleName, newRole);
await cache.set(scopedCacheKey(roleName), newRole);
}
return newRole.toObject() as IRole;
}
if (cache) {
await cache.set(roleName, role);
await cache.set(scopedCacheKey(roleName), role);
}
return role as unknown as IRole;
} catch (error) {
@ -135,9 +136,12 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
.exec();
if (cache) {
if (updates.name && updates.name !== roleName) {
await Promise.all([cache.set(roleName, null), cache.set(updates.name, role)]);
await Promise.all([
cache.set(scopedCacheKey(roleName), null),
cache.set(scopedCacheKey(updates.name), role),
]);
} else {
await cache.set(roleName, role);
await cache.set(scopedCacheKey(roleName), role);
}
}
return role as unknown as IRole;
@ -296,7 +300,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
const cache = deps.getCache?.(CacheKeys.ROLES);
const updatedRole = await Role.findOne({ name: roleName }).select('-__v').lean().exec();
if (cache) {
await cache.set(roleName, updatedRole);
await cache.set(scopedCacheKey(roleName), updatedRole);
}
logger.info(`Updated role '${roleName}' and removed old schema fields`);
@ -366,7 +370,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
const cache = deps.getCache?.(CacheKeys.ROLES);
if (cache) {
const updatedRole = await Role.findById(role._id).lean().exec();
await cache.set(role.name, updatedRole);
await cache.set(scopedCacheKey(role.name), updatedRole);
}
migratedCount++;
@ -418,7 +422,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
try {
const cache = deps.getCache?.(CacheKeys.ROLES);
if (cache) {
await cache.set(role.name, role.toObject());
await cache.set(scopedCacheKey(role.name), role.toObject());
}
} catch (cacheError) {
logger.error(`[createRoleByName] cache set failed for "${role.name}":`, cacheError);
@ -454,7 +458,7 @@ export function createRoleMethods(mongoose: typeof import('mongoose'), deps: Rol
// Setting null evicts the stale document. getRoleByName treats falsy cached
// values as a miss and falls through to the DB, so this does not provide
// negative caching — it only prevents serving the pre-deletion document.
await cache.set(roleName, null);
await cache.set(scopedCacheKey(roleName), null);
}
} catch (cacheError) {
logger.error(`[deleteRoleByName] cache invalidation failed for "${roleName}":`, cacheError);

View file

@ -0,0 +1,52 @@
import mongoose from 'mongoose';
import { createModels } from '../index';
/**
* Global symbol set by applyTenantIsolation() on every schema it processes.
* Recreated here via Symbol.for (the global registry) so the guard can detect
* plugin application without the plugin exporting anything.
*/
const TENANT_ISOLATION_APPLIED = Symbol.for('librechat:tenantIsolation');
/**
* Models that carry a `tenantId` field but intentionally do NOT use the
* tenant-isolation plugin. SystemGrant scopes tenancy manually inside its
* methods (see models/systemGrant). Adding an entry here must be a deliberate,
* reviewed decision that is the whole point of this guard.
*/
const MANUAL_TENANT_SCOPING = new Set<string>(['SystemGrant']);
function isPluginApplied(schema: mongoose.Schema): boolean {
return (schema as unknown as { [key: symbol]: boolean })[TENANT_ISOLATION_APPLIED] === true;
}
describe('tenant-isolation plugin coverage', () => {
beforeAll(() => {
createModels(mongoose);
});
it('applies the tenant-isolation plugin to every model that has a tenantId field', () => {
const missing: string[] = [];
for (const [name, model] of Object.entries(mongoose.models)) {
const hasTenantId = Boolean(model.schema.path('tenantId'));
if (!hasTenantId || MANUAL_TENANT_SCOPING.has(name)) {
continue;
}
if (!isPluginApplied(model.schema)) {
missing.push(name);
}
}
expect(missing).toEqual([]);
});
it('keeps the manual-scoping allowlist accurate (tenantId field, no plugin)', () => {
for (const name of MANUAL_TENANT_SCOPING) {
const model = mongoose.models[name];
expect(model).toBeDefined();
expect(Boolean(model.schema.path('tenantId'))).toBe(true);
expect(isPluginApplied(model.schema)).toBe(false);
}
});
});

View file

@ -154,6 +154,35 @@ describe('applyTenantIsolation', () => {
const tenantADoc = await TestModel.findOne({ tenantId: 'tenant-a' }).lean();
expect(tenantADoc!.name).toBe('updated');
});
it('injects tenantId filter into distinct', async () => {
const names = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
TestModel.distinct('name'),
);
expect(names).toEqual(['tenant-a-doc']);
});
it('injects tenantId filter into find().distinct() (op switches to distinct)', async () => {
const names = await tenantStorage.run({ tenantId: 'tenant-b' }, async () =>
TestModel.find().distinct('name'),
);
expect(names).toEqual(['tenant-b-doc']);
});
it('does not scope distinct when context is absent (non-strict)', async () => {
const names = await TestModel.distinct('name');
expect(names.sort()).toEqual(['no-tenant-doc', 'tenant-a-doc', 'tenant-b-doc']);
});
it('bypasses distinct filter for SYSTEM_TENANT_ID', async () => {
const names = await tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, async () =>
TestModel.distinct('name'),
);
expect(names).toHaveLength(3);
});
});
describe('aggregate filtering', () => {

View file

@ -137,6 +137,7 @@ export function applyTenantIsolation(schema: Schema): void {
schema.pre('find', queryMiddleware);
schema.pre('findOne', queryMiddleware);
schema.pre('distinct', queryMiddleware);
schema.pre('findOneAndUpdate', queryMiddleware);
schema.pre('findOneAndDelete', queryMiddleware);
schema.pre('findOneAndReplace', queryMiddleware);