mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
commit
9ff831e514
78 changed files with 3143 additions and 1721 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -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
237
.github/scripts/sync-helm-chart-tags.sh
vendored
Executable 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"
|
||||
42
.github/workflows/backend-review.yml
vendored
42
.github/workflows/backend-review.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/client.yml
vendored
4
.github/workflows/client.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/data-provider.yml
vendored
4
.github/workflows/data-provider.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/data-schemas.yml
vendored
4
.github/workflows/data-schemas.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/eslint-ci.yml
vendored
4
.github/workflows/eslint-ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
.github/workflows/frontend-review.yml
vendored
24
.github/workflows/frontend-review.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
2
.github/workflows/gitnexus-index.yml
vendored
2
.github/workflows/gitnexus-index.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
56
.github/workflows/helmcharts.yml
vendored
56
.github/workflows/helmcharts.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/locize-i18n-sync.yml
vendored
2
.github/workflows/locize-i18n-sync.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
85
.github/workflows/sync-helm-chart-tags.yml
vendored
Normal file
85
.github/workflows/sync-helm-chart-tags.yml
vendored
Normal 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
|
||||
4
.github/workflows/unused-packages.yml
vendored
4
.github/workflows/unused-packages.yml
vendored
|
|
@ -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
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.16.0
|
||||
|
|
@ -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/`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => {
|
|||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
sensitive: value.sensitive,
|
||||
}));
|
||||
server.authenticated = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const CustomEndpoint = ({
|
|||
label={`${endpoint} API Key`}
|
||||
labelClassName="mb-1"
|
||||
inputClassName="mb-2"
|
||||
secret
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'regenerator-runtime/runtime';
|
||||
import './polyfills/regeneratorRuntime';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './locales/i18n';
|
||||
import App from './App';
|
||||
|
|
|
|||
3
client/src/polyfills/regeneratorRuntime.js
Normal file
3
client/src/polyfills/regeneratorRuntime.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import regeneratorRuntime from 'regenerator-runtime/runtime';
|
||||
|
||||
globalThis.regeneratorRuntime ??= regeneratorRuntime;
|
||||
|
|
@ -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
2145
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
"name": "LibreChat",
|
||||
"version": "v0.8.6",
|
||||
"description": "",
|
||||
"packageManager": "npm@11.10.0",
|
||||
"packageManager": "npm@11.13.0",
|
||||
"workspaces": [
|
||||
"api",
|
||||
"client",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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__. ' +
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
114
packages/data-schemas/src/methods/aclEntry.tenant.spec.ts
Normal file
114
packages/data-schemas/src/methods/aclEntry.tenant.spec.ts
Normal 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)]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
141
packages/data-schemas/src/methods/role.cache.spec.ts
Normal file
141
packages/data-schemas/src/methods/role.cache.spec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue