Compare commits
1 Commits
fix/avatar
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6500f7eb3 |
21
.env.example
21
.env.example
@@ -196,7 +196,7 @@ GOOGLE_KEY=user_provided
|
||||
#============#
|
||||
|
||||
OPENAI_API_KEY=user_provided
|
||||
# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
|
||||
# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
|
||||
|
||||
DEBUG_OPENAI=false
|
||||
|
||||
@@ -459,9 +459,6 @@ OPENID_CALLBACK_URL=/oauth/openid/callback
|
||||
OPENID_REQUIRED_ROLE=
|
||||
OPENID_REQUIRED_ROLE_TOKEN_KIND=
|
||||
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
|
||||
OPENID_ADMIN_ROLE=
|
||||
OPENID_ADMIN_ROLE_PARAMETER_PATH=
|
||||
OPENID_ADMIN_ROLE_TOKEN_KIND=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's username
|
||||
OPENID_USERNAME_CLAIM=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
||||
@@ -653,12 +650,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Google tag manager id
|
||||
#ANALYTICS_GTM_ID=user provided google tag manager id
|
||||
|
||||
# limit conversation file imports to a certain number of bytes in size to avoid the container
|
||||
# maxing out memory limitations by unremarking this line and supplying a file size in bytes
|
||||
# such as the below example of 250 mib
|
||||
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
|
||||
|
||||
|
||||
#===============#
|
||||
# REDIS Options #
|
||||
#===============#
|
||||
@@ -702,16 +693,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
|
||||
|
||||
# Leader Election Configuration (for multi-instance deployments with Redis)
|
||||
# Duration in seconds that the leader lease is valid before it expires (default: 25)
|
||||
# LEADER_LEASE_DURATION=25
|
||||
# Interval in seconds at which the leader renews its lease (default: 10)
|
||||
# LEADER_RENEW_INTERVAL=10
|
||||
# Maximum number of retry attempts when renewing the lease fails (default: 3)
|
||||
# LEADER_RENEW_ATTEMPTS=3
|
||||
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
|
||||
# LEADER_RENEW_RETRY_DELAY=0.5
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
|
||||
87
.github/workflows/cache-integration-tests.yml
vendored
87
.github/workflows/cache-integration-tests.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: Cache Integration Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- release/*
|
||||
paths:
|
||||
- 'packages/api/src/cache/**'
|
||||
- 'packages/api/src/cluster/**'
|
||||
- 'redis-config/**'
|
||||
- '.github/workflows/cache-integration-tests.yml'
|
||||
|
||||
jobs:
|
||||
cache_integration_tests:
|
||||
name: Integration Tests that use actual Redis Cache
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Redis tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y redis-server redis-tools
|
||||
|
||||
- name: Start Single Redis Instance
|
||||
run: |
|
||||
redis-server --daemonize yes --port 6379
|
||||
sleep 2
|
||||
# Verify single Redis is running
|
||||
redis-cli -p 6379 ping || exit 1
|
||||
|
||||
- name: Start Redis Cluster
|
||||
working-directory: redis-config
|
||||
run: |
|
||||
chmod +x start-cluster.sh stop-cluster.sh
|
||||
./start-cluster.sh
|
||||
sleep 10
|
||||
# Verify cluster is running
|
||||
redis-cli -p 7001 cluster info || exit 1
|
||||
redis-cli -p 7002 cluster info || exit 1
|
||||
redis-cli -p 7003 cluster info || exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
npm run build:data-provider
|
||||
npm run build:data-schemas
|
||||
npm run build:api
|
||||
|
||||
- name: Run cache integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
|
||||
run: npm run test:cache-integration:core
|
||||
|
||||
- name: Run cluster integration tests
|
||||
working-directory: packages/api
|
||||
env:
|
||||
NODE_ENV: test
|
||||
USE_REDIS: true
|
||||
REDIS_URI: redis://127.0.0.1:6379
|
||||
run: npm run test:cache-integration:cluster
|
||||
|
||||
- name: Stop Redis Cluster
|
||||
if: always()
|
||||
working-directory: redis-config
|
||||
run: ./stop-cluster.sh || true
|
||||
|
||||
- name: Stop Single Redis Instance
|
||||
if: always()
|
||||
run: redis-cli -p 6379 shutdown || true
|
||||
@@ -1,2 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
[ -n "$CI" ] && exit 0
|
||||
npx lint-staged --config ./.husky/lint-staged.config.js
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.1-rc1
|
||||
# v0.8.0
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
const crypto = require('crypto');
|
||||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getBalanceConfig } = require('@librechat/api');
|
||||
const {
|
||||
getBalanceConfig,
|
||||
extractFileContext,
|
||||
encodeAndFormatAudios,
|
||||
encodeAndFormatVideos,
|
||||
encodeAndFormatDocuments,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
ErrorTypes,
|
||||
FileSources,
|
||||
supportsBalanceCheck,
|
||||
isAgentsEndpoint,
|
||||
isParamEndpoint,
|
||||
EModelEndpoint,
|
||||
ContentTypes,
|
||||
excludedKeys,
|
||||
EModelEndpoint,
|
||||
isParamEndpoint,
|
||||
isAgentsEndpoint,
|
||||
supportsBalanceCheck,
|
||||
ErrorTypes,
|
||||
Constants,
|
||||
} = require('librechat-data-provider');
|
||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { truncateToolCallOutputs } = require('./prompts');
|
||||
const countTokens = require('~/server/utils/countTokens');
|
||||
const { getFiles } = require('~/models/File');
|
||||
const TextStream = require('./TextStream');
|
||||
|
||||
@@ -1207,135 +1198,8 @@ class BaseClient {
|
||||
return await this.sendCompletion(payload, opts);
|
||||
}
|
||||
|
||||
async addDocuments(message, attachments) {
|
||||
const documentResult = await encodeAndFormatDocuments(
|
||||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.documents =
|
||||
documentResult.documents && documentResult.documents.length
|
||||
? documentResult.documents
|
||||
: undefined;
|
||||
return documentResult.files;
|
||||
}
|
||||
|
||||
async addVideos(message, attachments) {
|
||||
const videoResult = await encodeAndFormatVideos(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.videos =
|
||||
videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined;
|
||||
return videoResult.files;
|
||||
}
|
||||
|
||||
async addAudios(message, attachments) {
|
||||
const audioResult = await encodeAndFormatAudios(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
getStrategyFunctions,
|
||||
);
|
||||
message.audios =
|
||||
audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined;
|
||||
return audioResult.files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text context from attachments and sets it on the message.
|
||||
* This handles text that was already extracted from files (OCR, transcriptions, document text, etc.)
|
||||
* @param {TMessage} message - The message to add context to
|
||||
* @param {MongoFile[]} attachments - Array of file attachments
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async addFileContextToMessage(message, attachments) {
|
||||
const fileContext = await extractFileContext({
|
||||
attachments,
|
||||
req: this.options?.req,
|
||||
tokenCountFn: (text) => countTokens(text),
|
||||
});
|
||||
|
||||
if (fileContext) {
|
||||
message.fileContext = fileContext;
|
||||
}
|
||||
}
|
||||
|
||||
async processAttachments(message, attachments) {
|
||||
const categorizedAttachments = {
|
||||
images: [],
|
||||
videos: [],
|
||||
audios: [],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const allFiles = [];
|
||||
|
||||
for (const file of attachments) {
|
||||
/** @type {FileSources} */
|
||||
const source = file.source ?? FileSources.local;
|
||||
if (source === FileSources.text) {
|
||||
allFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
if (file.embedded === true || file.metadata?.fileIdentifier != null) {
|
||||
allFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
categorizedAttachments.images.push(file);
|
||||
} else if (file.type === 'application/pdf') {
|
||||
categorizedAttachments.documents.push(file);
|
||||
allFiles.push(file);
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
categorizedAttachments.videos.push(file);
|
||||
allFiles.push(file);
|
||||
} else if (file.type.startsWith('audio/')) {
|
||||
categorizedAttachments.audios.push(file);
|
||||
allFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const [imageFiles] = await Promise.all([
|
||||
categorizedAttachments.images.length > 0
|
||||
? this.addImageURLs(message, categorizedAttachments.images)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.documents.length > 0
|
||||
? this.addDocuments(message, categorizedAttachments.documents)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.videos.length > 0
|
||||
? this.addVideos(message, categorizedAttachments.videos)
|
||||
: Promise.resolve([]),
|
||||
categorizedAttachments.audios.length > 0
|
||||
? this.addAudios(message, categorizedAttachments.audios)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
allFiles.push(...imageFiles);
|
||||
|
||||
const seenFileIds = new Set();
|
||||
const uniqueFiles = [];
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (file.file_id && !seenFileIds.has(file.file_id)) {
|
||||
seenFileIds.add(file.file_id);
|
||||
uniqueFiles.push(file);
|
||||
} else if (!file.file_id) {
|
||||
uniqueFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TMessage[]} _messages
|
||||
* @returns {Promise<TMessage[]>}
|
||||
*/
|
||||
@@ -1384,8 +1248,7 @@ class BaseClient {
|
||||
{},
|
||||
);
|
||||
|
||||
await this.addFileContextToMessage(message, files);
|
||||
await this.processAttachments(message, files);
|
||||
await this.addImageURLs(message, files, this.visionMode);
|
||||
|
||||
this.message_file_map[message.messageId] = files;
|
||||
return message;
|
||||
|
||||
@@ -2,7 +2,7 @@ const { z } = require('zod');
|
||||
const axios = require('axios');
|
||||
const { Ollama } = require('ollama');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { resolveHeaders } = require('@librechat/api');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { deriveBaseURL } = require('~/utils');
|
||||
@@ -44,7 +44,6 @@ class OllamaClient {
|
||||
constructor(options = {}) {
|
||||
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
|
||||
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
|
||||
this.headers = options.headers ?? {};
|
||||
/** @type {Ollama} */
|
||||
this.client = new Ollama({ host });
|
||||
}
|
||||
@@ -52,32 +51,27 @@ class OllamaClient {
|
||||
/**
|
||||
* Fetches Ollama models from the specified base API path.
|
||||
* @param {string} baseURL
|
||||
* @param {Object} [options] - Optional configuration
|
||||
* @param {Partial<IUser>} [options.user] - User object for header resolution
|
||||
* @param {Record<string, string>} [options.headers] - Headers to include in the request
|
||||
* @returns {Promise<string[]>} The Ollama models.
|
||||
* @throws {Error} Throws if the Ollama API request fails
|
||||
*/
|
||||
static async fetchModels(baseURL, options = {}) {
|
||||
static async fetchModels(baseURL) {
|
||||
let models = [];
|
||||
if (!baseURL) {
|
||||
return models;
|
||||
}
|
||||
try {
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
} catch (error) {
|
||||
const logMessage =
|
||||
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
|
||||
logAxiosError({ message: logMessage, error });
|
||||
return [];
|
||||
}
|
||||
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
|
||||
const resolvedHeaders = resolveHeaders({
|
||||
headers: options.headers,
|
||||
user: options.user,
|
||||
});
|
||||
|
||||
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
|
||||
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const models = response.data.models.map((tag) => tag.name);
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,7 @@ const { runTitleChain } = require('./chains');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
const { tokenSplit } = require('./document');
|
||||
const BaseClient = require('./BaseClient');
|
||||
const { createLLM } = require('./llm');
|
||||
|
||||
class OpenAIClient extends BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
@@ -613,8 +614,65 @@ class OpenAIClient extends BaseClient {
|
||||
return (reply ?? '').trim();
|
||||
}
|
||||
|
||||
initializeLLM() {
|
||||
throw new Error('Deprecated');
|
||||
initializeLLM({
|
||||
model = openAISettings.model.default,
|
||||
modelName,
|
||||
temperature = 0.2,
|
||||
max_tokens,
|
||||
streaming,
|
||||
}) {
|
||||
const modelOptions = {
|
||||
modelName: modelName ?? model,
|
||||
temperature,
|
||||
user: this.user,
|
||||
};
|
||||
|
||||
if (max_tokens) {
|
||||
modelOptions.max_tokens = max_tokens;
|
||||
}
|
||||
|
||||
const configOptions = {};
|
||||
|
||||
if (this.langchainProxy) {
|
||||
configOptions.basePath = this.langchainProxy;
|
||||
}
|
||||
|
||||
if (this.useOpenRouter) {
|
||||
configOptions.basePath = 'https://openrouter.ai/api/v1';
|
||||
configOptions.baseOptions = {
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://librechat.ai',
|
||||
'X-Title': 'LibreChat',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { headers } = this.options;
|
||||
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
|
||||
configOptions.baseOptions = {
|
||||
headers: resolveHeaders({
|
||||
headers: {
|
||||
...headers,
|
||||
...configOptions?.baseOptions?.headers,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.options.proxy) {
|
||||
configOptions.httpAgent = new HttpsProxyAgent(this.options.proxy);
|
||||
configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy);
|
||||
}
|
||||
|
||||
const llm = createLLM({
|
||||
modelOptions,
|
||||
configOptions,
|
||||
openAIApiKey: this.apiKey,
|
||||
azure: this.azure,
|
||||
streaming,
|
||||
});
|
||||
|
||||
return llm;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
81
api/app/clients/llm/createLLM.js
Normal file
81
api/app/clients/llm/createLLM.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const { ChatOpenAI } = require('@langchain/openai');
|
||||
const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api');
|
||||
|
||||
/**
|
||||
* Creates a new instance of a language model (LLM) for chat interactions.
|
||||
*
|
||||
* @param {Object} options - The options for creating the LLM.
|
||||
* @param {ModelOptions} options.modelOptions - The options specific to the model, including modelName, temperature, presence_penalty, frequency_penalty, and other model-related settings.
|
||||
* @param {ConfigOptions} options.configOptions - Configuration options for the API requests, including proxy settings and custom headers.
|
||||
* @param {Callbacks} [options.callbacks] - Callback functions for managing the lifecycle of the LLM, including token buffers, context, and initial message count.
|
||||
* @param {boolean} [options.streaming=false] - Determines if the LLM should operate in streaming mode.
|
||||
* @param {string} options.openAIApiKey - The API key for OpenAI, used for authentication.
|
||||
* @param {AzureOptions} [options.azure={}] - Optional Azure-specific configurations. If provided, Azure configurations take precedence over OpenAI configurations.
|
||||
*
|
||||
* @returns {ChatOpenAI} An instance of the ChatOpenAI class, configured with the provided options.
|
||||
*
|
||||
* @example
|
||||
* const llm = createLLM({
|
||||
* modelOptions: { modelName: 'gpt-4o-mini', temperature: 0.2 },
|
||||
* configOptions: { basePath: 'https://example.api/path' },
|
||||
* callbacks: { onMessage: handleMessage },
|
||||
* openAIApiKey: 'your-api-key'
|
||||
* });
|
||||
*/
|
||||
function createLLM({
|
||||
modelOptions,
|
||||
configOptions,
|
||||
callbacks,
|
||||
streaming = false,
|
||||
openAIApiKey,
|
||||
azure = {},
|
||||
}) {
|
||||
let credentials = { openAIApiKey };
|
||||
let configuration = {
|
||||
apiKey: openAIApiKey,
|
||||
...(configOptions.basePath && { baseURL: configOptions.basePath }),
|
||||
};
|
||||
|
||||
/** @type {AzureOptions} */
|
||||
let azureOptions = {};
|
||||
if (azure) {
|
||||
const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME);
|
||||
|
||||
credentials = {};
|
||||
configuration = {};
|
||||
azureOptions = azure;
|
||||
|
||||
azureOptions.azureOpenAIApiDeploymentName = useModelName
|
||||
? sanitizeModelName(modelOptions.modelName)
|
||||
: azureOptions.azureOpenAIApiDeploymentName;
|
||||
}
|
||||
|
||||
if (azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) {
|
||||
modelOptions.modelName = process.env.AZURE_OPENAI_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
if (azure && configOptions.basePath) {
|
||||
const azureURL = constructAzureURL({
|
||||
baseURL: configOptions.basePath,
|
||||
azureOptions,
|
||||
});
|
||||
azureOptions.azureOpenAIBasePath = azureURL.split(
|
||||
`/${azureOptions.azureOpenAIApiDeploymentName}`,
|
||||
)[0];
|
||||
}
|
||||
|
||||
return new ChatOpenAI(
|
||||
{
|
||||
streaming,
|
||||
credentials,
|
||||
configuration,
|
||||
...azureOptions,
|
||||
...modelOptions,
|
||||
...credentials,
|
||||
callbacks,
|
||||
},
|
||||
configOptions,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = createLLM;
|
||||
@@ -1,5 +1,7 @@
|
||||
const createLLM = require('./createLLM');
|
||||
const createCoherePayload = require('./createCoherePayload');
|
||||
|
||||
module.exports = {
|
||||
createLLM,
|
||||
createCoherePayload,
|
||||
};
|
||||
|
||||
31
api/app/clients/memory/summaryBuffer.demo.js
Normal file
31
api/app/clients/memory/summaryBuffer.demo.js
Normal file
@@ -0,0 +1,31 @@
|
||||
require('dotenv').config();
|
||||
const { ChatOpenAI } = require('@langchain/openai');
|
||||
const { getBufferString, ConversationSummaryBufferMemory } = require('langchain/memory');
|
||||
|
||||
const chatPromptMemory = new ConversationSummaryBufferMemory({
|
||||
llm: new ChatOpenAI({ modelName: 'gpt-4o-mini', temperature: 0 }),
|
||||
maxTokenLimit: 10,
|
||||
returnMessages: true,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await chatPromptMemory.saveContext({ input: 'hi my name\'s Danny' }, { output: 'whats up' });
|
||||
await chatPromptMemory.saveContext({ input: 'not much you' }, { output: 'not much' });
|
||||
await chatPromptMemory.saveContext(
|
||||
{ input: 'are you excited for the olympics?' },
|
||||
{ output: 'not really' },
|
||||
);
|
||||
|
||||
// We can also utilize the predict_new_summary method directly.
|
||||
const messages = await chatPromptMemory.chatHistory.getMessages();
|
||||
console.log('MESSAGES\n\n');
|
||||
console.log(JSON.stringify(messages));
|
||||
const previous_summary = '';
|
||||
const predictSummary = await chatPromptMemory.predictNewSummary(messages, previous_summary);
|
||||
console.log('SUMMARY\n\n');
|
||||
console.log(JSON.stringify(getBufferString([{ role: 'system', content: predictSummary }])));
|
||||
|
||||
// const { history } = await chatPromptMemory.loadMemoryVariables({});
|
||||
// console.log('HISTORY\n\n');
|
||||
// console.log(JSON.stringify(history));
|
||||
})();
|
||||
@@ -3,7 +3,6 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
|
||||
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
|
||||
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
|
||||
|
||||
/** @deprecated */
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
|
||||
|
||||
@@ -116,7 +115,6 @@ Here are some examples of correct usage of artifacts:
|
||||
</assistant_response>
|
||||
</example>
|
||||
</examples>`;
|
||||
|
||||
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
|
||||
|
||||
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||
@@ -167,10 +165,6 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
- Markdown: "text/markdown" or "text/md"
|
||||
- The user interface will render Markdown content placed within the artifact tags.
|
||||
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
- React Components: "application/vnd.react"
|
||||
@@ -372,10 +366,6 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
- Markdown: "text/markdown" or "text/md"
|
||||
- The user interface will render Markdown content placed within the artifact tags.
|
||||
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
- React Components: "application/vnd.react"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const { z } = require('zod');
|
||||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
const fetch = require('node-fetch');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { ProxyAgent, fetch } = require('undici');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { Tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getImageBasename } = require('@librechat/api');
|
||||
|
||||
@@ -5,7 +5,6 @@ const FormData = require('form-data');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, oaiToolkit } = require('@librechat/api');
|
||||
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -349,7 +348,16 @@ Error Message: ${error.message}`);
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
try {
|
||||
const url = new URL(process.env.PROXY);
|
||||
axiosConfig.proxy = {
|
||||
host: url.hostname.replace(/^\[|\]$/g, ''),
|
||||
port: url.port ? parseInt(url.port, 10) : undefined,
|
||||
protocol: url.protocol.replace(':', ''),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error parsing proxy URL:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {
|
||||
|
||||
@@ -448,7 +448,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
}
|
||||
if (!availableTools) {
|
||||
try {
|
||||
availableTools = await getMCPServerTools(safeUser.id, serverName);
|
||||
availableTools = await getMCPServerTools(serverName);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ jest.mock('~/server/services/Config', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { BaseLLM } = require('@langchain/openai');
|
||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||
|
||||
const { User } = require('~/db/models');
|
||||
@@ -171,6 +172,7 @@ describe('Tool Handlers', () => {
|
||||
beforeAll(async () => {
|
||||
const toolMap = await loadTools({
|
||||
user: fakeUser._id,
|
||||
model: BaseLLM,
|
||||
tools: sampleTools,
|
||||
returnMap: true,
|
||||
useSpecs: true,
|
||||
@@ -264,6 +266,7 @@ describe('Tool Handlers', () => {
|
||||
it('returns an empty object when no tools are requested', async () => {
|
||||
toolFunctions = await loadTools({
|
||||
user: fakeUser._id,
|
||||
model: BaseLLM,
|
||||
returnMap: true,
|
||||
useSpecs: true,
|
||||
});
|
||||
@@ -273,6 +276,7 @@ describe('Tool Handlers', () => {
|
||||
process.env.SD_WEBUI_URL = mockCredential;
|
||||
toolFunctions = await loadTools({
|
||||
user: fakeUser._id,
|
||||
model: BaseLLM,
|
||||
tools: ['stable-diffusion'],
|
||||
functions: true,
|
||||
returnMap: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { CacheKeys } from 'librechat-data-provider';
|
||||
import { math, isEnabled } from '~/utils';
|
||||
const fs = require('fs');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { math, isEnabled } = require('@librechat/api');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
|
||||
// To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys.
|
||||
// This prefix is usually the deployment ID, which is often passed to the container or pod as an env var.
|
||||
@@ -25,7 +25,7 @@ const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAM
|
||||
|
||||
// Validate against CacheKeys enum
|
||||
if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
|
||||
const validKeys = Object.values(CacheKeys) as string[];
|
||||
const validKeys = Object.values(CacheKeys);
|
||||
const invalidKeys = FORCED_IN_MEMORY_CACHE_NAMESPACES.filter((key) => !validKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
@@ -38,15 +38,15 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
|
||||
/** Helper function to safely read Redis CA certificate from file
|
||||
* @returns {string|null} The contents of the CA certificate file, or null if not set or on error
|
||||
*/
|
||||
const getRedisCA = (): string | null => {
|
||||
const getRedisCA = () => {
|
||||
const caPath = process.env.REDIS_CA;
|
||||
if (!caPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(caPath)) {
|
||||
return readFileSync(caPath, 'utf8');
|
||||
if (fs.existsSync(caPath)) {
|
||||
return fs.readFileSync(caPath, 'utf8');
|
||||
} else {
|
||||
logger.warn(`Redis CA certificate file not found: ${caPath}`);
|
||||
return null;
|
||||
@@ -64,8 +64,7 @@ const cacheConfig = {
|
||||
REDIS_USERNAME: process.env.REDIS_USERNAME,
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||
REDIS_CA: getRedisCA(),
|
||||
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR ?? ''] || REDIS_KEY_PREFIX || '',
|
||||
GLOBAL_PREFIX_SEPARATOR: '::',
|
||||
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '',
|
||||
REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40),
|
||||
REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0),
|
||||
/** Max delay between reconnection attempts in ms */
|
||||
@@ -87,4 +86,4 @@ const cacheConfig = {
|
||||
BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours
|
||||
};
|
||||
|
||||
export { cacheConfig };
|
||||
module.exports = { cacheConfig };
|
||||
@@ -1,8 +1,12 @@
|
||||
const fs = require('fs');
|
||||
|
||||
describe('cacheConfig', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let originalEnv;
|
||||
let originalReadFileSync;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
originalReadFileSync = fs.readFileSync;
|
||||
|
||||
// Clear all related env vars first
|
||||
delete process.env.REDIS_URI;
|
||||
@@ -14,116 +18,116 @@ describe('cacheConfig', () => {
|
||||
delete process.env.REDIS_PING_INTERVAL;
|
||||
delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES;
|
||||
|
||||
// Clear module cache
|
||||
// Clear require cache
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('REDIS_KEY_PREFIX validation and resolution', () => {
|
||||
test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', async () => {
|
||||
test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => {
|
||||
process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID';
|
||||
process.env.REDIS_KEY_PREFIX = 'manual-prefix';
|
||||
|
||||
await expect(async () => {
|
||||
await import('../cacheConfig');
|
||||
}).rejects.toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.');
|
||||
expect(() => {
|
||||
require('./cacheConfig');
|
||||
}).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.');
|
||||
});
|
||||
|
||||
test('should resolve REDIS_KEY_PREFIX from variable reference', async () => {
|
||||
test('should resolve REDIS_KEY_PREFIX from variable reference', () => {
|
||||
process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID';
|
||||
process.env.DEPLOYMENT_ID = 'test-deployment-123';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123');
|
||||
});
|
||||
|
||||
test('should use direct REDIS_KEY_PREFIX value', async () => {
|
||||
test('should use direct REDIS_KEY_PREFIX value', () => {
|
||||
process.env.REDIS_KEY_PREFIX = 'direct-prefix';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix');
|
||||
});
|
||||
|
||||
test('should default to empty string when no prefix is configured', async () => {
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
test('should default to empty string when no prefix is configured', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||
});
|
||||
|
||||
test('should handle empty variable reference', async () => {
|
||||
test('should handle empty variable reference', () => {
|
||||
process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR';
|
||||
process.env.EMPTY_VAR = '';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||
});
|
||||
|
||||
test('should handle undefined variable reference', async () => {
|
||||
test('should handle undefined variable reference', () => {
|
||||
process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_KEY_PREFIX).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('USE_REDIS and REDIS_URI validation', () => {
|
||||
test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', async () => {
|
||||
test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => {
|
||||
process.env.USE_REDIS = 'true';
|
||||
|
||||
await expect(async () => {
|
||||
await import('../cacheConfig');
|
||||
}).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||
expect(() => {
|
||||
require('./cacheConfig');
|
||||
}).toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||
});
|
||||
|
||||
test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', async () => {
|
||||
test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => {
|
||||
process.env.USE_REDIS = 'true';
|
||||
process.env.REDIS_URI = 'redis://localhost:6379';
|
||||
|
||||
const importModule = async () => {
|
||||
await import('../cacheConfig');
|
||||
};
|
||||
await expect(importModule()).resolves.not.toThrow();
|
||||
expect(() => {
|
||||
require('./cacheConfig');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('should handle empty REDIS_URI when USE_REDIS is enabled', async () => {
|
||||
test('should handle empty REDIS_URI when USE_REDIS is enabled', () => {
|
||||
process.env.USE_REDIS = 'true';
|
||||
process.env.REDIS_URI = '';
|
||||
|
||||
await expect(async () => {
|
||||
await import('../cacheConfig');
|
||||
}).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||
expect(() => {
|
||||
require('./cacheConfig');
|
||||
}).toThrow('USE_REDIS is enabled but REDIS_URI is not set.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('USE_REDIS_CLUSTER configuration', () => {
|
||||
test('should default to false when USE_REDIS_CLUSTER is not set', async () => {
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
test('should default to false when USE_REDIS_CLUSTER is not set', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false);
|
||||
});
|
||||
|
||||
test('should be false when USE_REDIS_CLUSTER is set to false', async () => {
|
||||
test('should be false when USE_REDIS_CLUSTER is set to false', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'false';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false);
|
||||
});
|
||||
|
||||
test('should be true when USE_REDIS_CLUSTER is set to true', async () => {
|
||||
test('should be true when USE_REDIS_CLUSTER is set to true', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'true';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true);
|
||||
});
|
||||
|
||||
test('should work with USE_REDIS enabled and REDIS_URI set', async () => {
|
||||
test('should work with USE_REDIS enabled and REDIS_URI set', () => {
|
||||
process.env.USE_REDIS_CLUSTER = 'true';
|
||||
process.env.USE_REDIS = 'true';
|
||||
process.env.REDIS_URI = 'redis://localhost:6379';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true);
|
||||
expect(cacheConfig.USE_REDIS).toBe(true);
|
||||
expect(cacheConfig.REDIS_URI).toBe('redis://localhost:6379');
|
||||
@@ -131,51 +135,54 @@ describe('cacheConfig', () => {
|
||||
});
|
||||
|
||||
describe('REDIS_CA file reading', () => {
|
||||
test('should be null when REDIS_CA is not set', async () => {
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
test('should be null when REDIS_CA is not set', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_CA).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('REDIS_PING_INTERVAL configuration', () => {
|
||||
test('should default to 0 when REDIS_PING_INTERVAL is not set', async () => {
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
test('should default to 0 when REDIS_PING_INTERVAL is not set', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_PING_INTERVAL).toBe(0);
|
||||
});
|
||||
|
||||
test('should use provided REDIS_PING_INTERVAL value', async () => {
|
||||
test('should use provided REDIS_PING_INTERVAL value', () => {
|
||||
process.env.REDIS_PING_INTERVAL = '300';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.REDIS_PING_INTERVAL).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => {
|
||||
test('should parse comma-separated cache keys correctly', async () => {
|
||||
test('should parse comma-separated cache keys correctly', () => {
|
||||
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES ';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['ROLES', 'MESSAGES']);
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([
|
||||
'ROLES',
|
||||
'MESSAGES',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should throw error for invalid cache keys', async () => {
|
||||
test('should throw error for invalid cache keys', () => {
|
||||
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'INVALID_KEY,ROLES';
|
||||
|
||||
await expect(async () => {
|
||||
await import('../cacheConfig');
|
||||
}).rejects.toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY');
|
||||
expect(() => {
|
||||
require('./cacheConfig');
|
||||
}).toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY');
|
||||
});
|
||||
|
||||
test('should handle empty string gracefully', async () => {
|
||||
test('should handle empty string gracefully', () => {
|
||||
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = '';
|
||||
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle undefined env var gracefully', async () => {
|
||||
const { cacheConfig } = await import('../cacheConfig');
|
||||
test('should handle undefined env var gracefully', () => {
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]);
|
||||
});
|
||||
});
|
||||
108
api/cache/cacheFactory.js
vendored
Normal file
108
api/cache/cacheFactory.js
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
const KeyvRedis = require('@keyv/redis').default;
|
||||
const { Keyv } = require('keyv');
|
||||
const { RedisStore } = require('rate-limit-redis');
|
||||
const { Time } = require('librechat-data-provider');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { RedisStore: ConnectRedis } = require('connect-redis');
|
||||
const MemoryStore = require('memorystore')(require('express-session'));
|
||||
const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
const { violationFile } = require('./keyvFiles');
|
||||
|
||||
/**
|
||||
* Creates a cache instance using Redis or a fallback store. Suitable for general caching needs.
|
||||
* @param {string} namespace - The cache namespace.
|
||||
* @param {number} [ttl] - Time to live for cache entries.
|
||||
* @param {object} [fallbackStore] - Optional fallback store if Redis is not used.
|
||||
* @returns {Keyv} Cache instance.
|
||||
*/
|
||||
const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => {
|
||||
if (
|
||||
cacheConfig.USE_REDIS &&
|
||||
!cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)
|
||||
) {
|
||||
try {
|
||||
const keyvRedis = new KeyvRedis(keyvRedisClient);
|
||||
const cache = new Keyv(keyvRedis, { namespace, ttl });
|
||||
keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX;
|
||||
keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR;
|
||||
|
||||
cache.on('error', (err) => {
|
||||
logger.error(`Cache error in namespace ${namespace}:`, err);
|
||||
});
|
||||
|
||||
return cache;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl });
|
||||
return new Keyv({ namespace, ttl });
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a cache instance for storing violation data.
|
||||
* Uses a file-based fallback store if Redis is not enabled.
|
||||
* @param {string} namespace - The cache namespace for violations.
|
||||
* @param {number} [ttl] - Time to live for cache entries.
|
||||
* @returns {Keyv} Cache instance for violations.
|
||||
*/
|
||||
const violationCache = (namespace, ttl = undefined) => {
|
||||
return standardCache(`violations:${namespace}`, ttl, violationFile);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a session cache instance using Redis or in-memory store.
|
||||
* @param {string} namespace - The session namespace.
|
||||
* @param {number} [ttl] - Time to live for session entries.
|
||||
* @returns {MemoryStore | ConnectRedis} Session store instance.
|
||||
*/
|
||||
const sessionCache = (namespace, ttl = undefined) => {
|
||||
namespace = namespace.endsWith(':') ? namespace : `${namespace}:`;
|
||||
if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY });
|
||||
const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace });
|
||||
if (ioredisClient) {
|
||||
ioredisClient.on('error', (err) => {
|
||||
logger.error(`Session store Redis error for namespace ${namespace}:`, err);
|
||||
});
|
||||
}
|
||||
return store;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a rate limiter cache using Redis.
|
||||
* @param {string} prefix - The key prefix for rate limiting.
|
||||
* @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used.
|
||||
*/
|
||||
const limiterCache = (prefix) => {
|
||||
if (!prefix) throw new Error('prefix is required');
|
||||
if (!cacheConfig.USE_REDIS) return undefined;
|
||||
prefix = prefix.endsWith(':') ? prefix : `${prefix}:`;
|
||||
|
||||
try {
|
||||
if (!ioredisClient) {
|
||||
logger.warn(`Redis client not available for rate limiter with prefix ${prefix}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new RedisStore({ sendCommand, prefix });
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const sendCommand = (...args) => {
|
||||
if (!ioredisClient) {
|
||||
logger.warn('Redis client not available for command execution');
|
||||
return Promise.reject(new Error('Redis client not available'));
|
||||
}
|
||||
|
||||
return ioredisClient.call(...args).catch((err) => {
|
||||
logger.error('Redis command execution failed:', err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { standardCache, sessionCache, violationCache, limiterCache };
|
||||
432
api/cache/cacheFactory.spec.js
vendored
Normal file
432
api/cache/cacheFactory.spec.js
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
const { Time } = require('librechat-data-provider');
|
||||
|
||||
// Mock dependencies first
|
||||
const mockKeyvRedis = {
|
||||
namespace: '',
|
||||
keyPrefixSeparator: '',
|
||||
};
|
||||
|
||||
const mockKeyv = jest.fn().mockReturnValue({
|
||||
mock: 'keyv',
|
||||
on: jest.fn(),
|
||||
});
|
||||
const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' });
|
||||
const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' });
|
||||
const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' });
|
||||
|
||||
const mockIoredisClient = {
|
||||
call: jest.fn(),
|
||||
on: jest.fn(),
|
||||
};
|
||||
|
||||
const mockKeyvRedisClient = {};
|
||||
const mockViolationFile = {};
|
||||
|
||||
// Mock modules before requiring the main module
|
||||
jest.mock('@keyv/redis', () => ({
|
||||
default: jest.fn().mockImplementation(() => mockKeyvRedis),
|
||||
}));
|
||||
|
||||
jest.mock('keyv', () => ({
|
||||
Keyv: mockKeyv,
|
||||
}));
|
||||
|
||||
jest.mock('./cacheConfig', () => ({
|
||||
cacheConfig: {
|
||||
USE_REDIS: false,
|
||||
REDIS_KEY_PREFIX: 'test',
|
||||
FORCED_IN_MEMORY_CACHE_NAMESPACES: [],
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./redisClients', () => ({
|
||||
keyvRedisClient: mockKeyvRedisClient,
|
||||
ioredisClient: mockIoredisClient,
|
||||
GLOBAL_PREFIX_SEPARATOR: '::',
|
||||
}));
|
||||
|
||||
jest.mock('./keyvFiles', () => ({
|
||||
violationFile: mockViolationFile,
|
||||
}));
|
||||
|
||||
jest.mock('connect-redis', () => ({ RedisStore: mockConnectRedis }));
|
||||
|
||||
jest.mock('memorystore', () => jest.fn(() => mockMemoryStore));
|
||||
|
||||
jest.mock('rate-limit-redis', () => ({
|
||||
RedisStore: mockRedisStore,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
|
||||
describe('cacheFactory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset cache config mock
|
||||
cacheConfig.USE_REDIS = false;
|
||||
cacheConfig.REDIS_KEY_PREFIX = 'test';
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [];
|
||||
});
|
||||
|
||||
describe('redisCache', () => {
|
||||
it('should create Redis cache when USE_REDIS is true', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'test-namespace';
|
||||
const ttl = 3600;
|
||||
|
||||
standardCache(namespace, ttl);
|
||||
|
||||
expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient);
|
||||
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl });
|
||||
expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX);
|
||||
expect(mockKeyvRedis.keyPrefixSeparator).toBe('::');
|
||||
});
|
||||
|
||||
it('should create Redis cache with undefined ttl when not provided', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'test-namespace';
|
||||
|
||||
standardCache(namespace);
|
||||
|
||||
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined });
|
||||
});
|
||||
|
||||
it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
const namespace = 'test-namespace';
|
||||
const ttl = 3600;
|
||||
const fallbackStore = { some: 'store' };
|
||||
|
||||
standardCache(namespace, ttl, fallbackStore);
|
||||
|
||||
expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl });
|
||||
});
|
||||
|
||||
it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
const namespace = 'test-namespace';
|
||||
const ttl = 3600;
|
||||
|
||||
standardCache(namespace, ttl);
|
||||
|
||||
expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl });
|
||||
});
|
||||
|
||||
it('should handle namespace and ttl as undefined', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
|
||||
standardCache();
|
||||
|
||||
expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined });
|
||||
});
|
||||
|
||||
it('should use fallback when namespace is in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['forced-memory'];
|
||||
const namespace = 'forced-memory';
|
||||
const ttl = 3600;
|
||||
|
||||
standardCache(namespace, ttl);
|
||||
|
||||
expect(require('@keyv/redis').default).not.toHaveBeenCalled();
|
||||
expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl });
|
||||
});
|
||||
|
||||
it('should use Redis when namespace is not in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['other-namespace'];
|
||||
const namespace = 'test-namespace';
|
||||
const ttl = 3600;
|
||||
|
||||
standardCache(namespace, ttl);
|
||||
|
||||
expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient);
|
||||
expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl });
|
||||
});
|
||||
|
||||
it('should throw error when Redis cache creation fails', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'test-namespace';
|
||||
const ttl = 3600;
|
||||
const testError = new Error('Redis connection failed');
|
||||
|
||||
const KeyvRedis = require('@keyv/redis').default;
|
||||
KeyvRedis.mockImplementationOnce(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
expect(() => standardCache(namespace, ttl)).toThrow('Redis connection failed');
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
`Failed to create Redis cache for namespace ${namespace}:`,
|
||||
testError,
|
||||
);
|
||||
|
||||
expect(mockKeyv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('violationCache', () => {
|
||||
it('should create violation cache with prefixed namespace', () => {
|
||||
const namespace = 'test-violations';
|
||||
const ttl = 7200;
|
||||
|
||||
// We can't easily mock the internal redisCache call since it's in the same module
|
||||
// But we can test that the function executes without throwing
|
||||
expect(() => violationCache(namespace, ttl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should create violation cache with undefined ttl', () => {
|
||||
const namespace = 'test-violations';
|
||||
|
||||
violationCache(namespace);
|
||||
|
||||
// The function should call redisCache with violations: prefixed namespace
|
||||
// Since we can't easily mock the internal redisCache call, we test the behavior
|
||||
expect(() => violationCache(namespace)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle undefined namespace', () => {
|
||||
expect(() => violationCache(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCache', () => {
|
||||
it('should return MemoryStore when USE_REDIS is false', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
const namespace = 'sessions';
|
||||
const ttl = 86400;
|
||||
|
||||
const result = sessionCache(namespace, ttl);
|
||||
|
||||
expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY });
|
||||
expect(result).toBe(mockMemoryStore());
|
||||
});
|
||||
|
||||
it('should return ConnectRedis when USE_REDIS is true', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions';
|
||||
const ttl = 86400;
|
||||
|
||||
const result = sessionCache(namespace, ttl);
|
||||
|
||||
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||
client: mockIoredisClient,
|
||||
ttl,
|
||||
prefix: `${namespace}:`,
|
||||
});
|
||||
expect(result).toBe(mockConnectRedis());
|
||||
});
|
||||
|
||||
it('should add colon to namespace if not present', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions';
|
||||
|
||||
sessionCache(namespace);
|
||||
|
||||
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||
client: mockIoredisClient,
|
||||
ttl: undefined,
|
||||
prefix: 'sessions:',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add colon to namespace if already present', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions:';
|
||||
|
||||
sessionCache(namespace);
|
||||
|
||||
expect(mockConnectRedis).toHaveBeenCalledWith({
|
||||
client: mockIoredisClient,
|
||||
ttl: undefined,
|
||||
prefix: 'sessions:',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined ttl', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
const namespace = 'sessions';
|
||||
|
||||
sessionCache(namespace);
|
||||
|
||||
expect(mockMemoryStore).toHaveBeenCalledWith({
|
||||
ttl: undefined,
|
||||
checkPeriod: Time.ONE_DAY,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when ConnectRedis constructor fails', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions';
|
||||
const ttl = 86400;
|
||||
|
||||
// Mock ConnectRedis to throw an error during construction
|
||||
const redisError = new Error('Redis connection failed');
|
||||
mockConnectRedis.mockImplementationOnce(() => {
|
||||
throw redisError;
|
||||
});
|
||||
|
||||
// The error should propagate up, not be caught
|
||||
expect(() => sessionCache(namespace, ttl)).toThrow('Redis connection failed');
|
||||
|
||||
// Verify that MemoryStore was NOT used as fallback
|
||||
expect(mockMemoryStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register error handler but let errors propagate to Express', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions';
|
||||
|
||||
// Create a mock session store with middleware methods
|
||||
const mockSessionStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
mockConnectRedis.mockReturnValue(mockSessionStore);
|
||||
|
||||
const store = sessionCache(namespace);
|
||||
|
||||
// Verify error handler was registered
|
||||
expect(mockIoredisClient.on).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
|
||||
// Get the error handler
|
||||
const errorHandler = mockIoredisClient.on.mock.calls.find((call) => call[0] === 'error')[1];
|
||||
|
||||
// Simulate an error from Redis during a session operation
|
||||
const redisError = new Error('Socket closed unexpectedly');
|
||||
|
||||
// The error handler should log but not swallow the error
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
errorHandler(redisError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
`Session store Redis error for namespace ${namespace}::`,
|
||||
redisError,
|
||||
);
|
||||
|
||||
// Now simulate what happens when session middleware tries to use the store
|
||||
const callback = jest.fn();
|
||||
mockSessionStore.get.mockImplementation((sid, cb) => {
|
||||
cb(new Error('Redis connection lost'));
|
||||
});
|
||||
|
||||
// Call the store's get method (as Express session would)
|
||||
store.get('test-session-id', callback);
|
||||
|
||||
// The error should be passed to the callback, not swallowed
|
||||
expect(callback).toHaveBeenCalledWith(new Error('Redis connection lost'));
|
||||
});
|
||||
|
||||
it('should handle null ioredisClient gracefully', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const namespace = 'sessions';
|
||||
|
||||
// Temporarily set ioredisClient to null (simulating connection not established)
|
||||
const originalClient = require('./redisClients').ioredisClient;
|
||||
require('./redisClients').ioredisClient = null;
|
||||
|
||||
// ConnectRedis might accept null client but would fail on first use
|
||||
// The important thing is it doesn't throw uncaught exceptions during construction
|
||||
const store = sessionCache(namespace);
|
||||
expect(store).toBeDefined();
|
||||
|
||||
// Restore original client
|
||||
require('./redisClients').ioredisClient = originalClient;
|
||||
});
|
||||
});
|
||||
|
||||
describe('limiterCache', () => {
|
||||
it('should return undefined when USE_REDIS is false', () => {
|
||||
cacheConfig.USE_REDIS = false;
|
||||
const result = limiterCache('prefix');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return RedisStore when USE_REDIS is true', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
const result = limiterCache('rate-limit');
|
||||
|
||||
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||
sendCommand: expect.any(Function),
|
||||
prefix: `rate-limit:`,
|
||||
});
|
||||
expect(result).toBe(mockRedisStore());
|
||||
});
|
||||
|
||||
it('should add colon to prefix if not present', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
limiterCache('rate-limit');
|
||||
|
||||
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||
sendCommand: expect.any(Function),
|
||||
prefix: 'rate-limit:',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add colon to prefix if already present', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
limiterCache('rate-limit:');
|
||||
|
||||
expect(mockRedisStore).toHaveBeenCalledWith({
|
||||
sendCommand: expect.any(Function),
|
||||
prefix: 'rate-limit:',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass sendCommand function that calls ioredisClient.call', async () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
mockIoredisClient.call.mockResolvedValue('test-value');
|
||||
|
||||
limiterCache('rate-limit');
|
||||
|
||||
const sendCommandCall = mockRedisStore.mock.calls[0][0];
|
||||
const sendCommand = sendCommandCall.sendCommand;
|
||||
|
||||
// Test that sendCommand properly delegates to ioredisClient.call
|
||||
const args = ['GET', 'test-key'];
|
||||
const result = await sendCommand(...args);
|
||||
|
||||
expect(mockIoredisClient.call).toHaveBeenCalledWith(...args);
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should handle sendCommand errors properly', async () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
|
||||
// Mock the call method to reject with an error
|
||||
const testError = new Error('Redis error');
|
||||
mockIoredisClient.call.mockRejectedValue(testError);
|
||||
|
||||
limiterCache('rate-limit');
|
||||
|
||||
const sendCommandCall = mockRedisStore.mock.calls[0][0];
|
||||
const sendCommand = sendCommandCall.sendCommand;
|
||||
|
||||
// Test that sendCommand properly handles errors
|
||||
const args = ['GET', 'test-key'];
|
||||
|
||||
await expect(sendCommand(...args)).rejects.toThrow('Redis error');
|
||||
expect(mockIoredisClient.call).toHaveBeenCalledWith(...args);
|
||||
});
|
||||
|
||||
it('should handle undefined prefix', () => {
|
||||
cacheConfig.USE_REDIS = true;
|
||||
expect(() => limiterCache()).toThrow('prefix is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
14
api/cache/getLogStores.js
vendored
14
api/cache/getLogStores.js
vendored
@@ -1,13 +1,9 @@
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
const { Keyv } = require('keyv');
|
||||
const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
logFile,
|
||||
keyvMongo,
|
||||
cacheConfig,
|
||||
sessionCache,
|
||||
standardCache,
|
||||
violationCache,
|
||||
} = require('@librechat/api');
|
||||
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
|
||||
const { logFile } = require('./keyvFiles');
|
||||
const keyvMongo = require('./keyvMongo');
|
||||
const { standardCache, sessionCache, violationCache } = require('./cacheFactory');
|
||||
|
||||
const namespaces = {
|
||||
[ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }),
|
||||
|
||||
3
api/cache/index.js
vendored
3
api/cache/index.js
vendored
@@ -1,4 +1,5 @@
|
||||
const keyvFiles = require('./keyvFiles');
|
||||
const getLogStores = require('./getLogStores');
|
||||
const logViolation = require('./logViolation');
|
||||
|
||||
module.exports = { getLogStores, logViolation };
|
||||
module.exports = { ...keyvFiles, getLogStores, logViolation };
|
||||
|
||||
9
api/cache/keyvFiles.js
vendored
Normal file
9
api/cache/keyvFiles.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
const { KeyvFile } = require('keyv-file');
|
||||
|
||||
const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20);
|
||||
const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners(20);
|
||||
|
||||
module.exports = {
|
||||
logFile,
|
||||
violationFile,
|
||||
};
|
||||
@@ -1,69 +1,65 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { EventEmitter } from 'events';
|
||||
import { GridFSBucket } from 'mongodb';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { Db, ReadPreference, Collection } from 'mongodb';
|
||||
// api/cache/keyvMongo.js
|
||||
const mongoose = require('mongoose');
|
||||
const EventEmitter = require('events');
|
||||
const { GridFSBucket } = require('mongodb');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
interface KeyvMongoOptions {
|
||||
url?: string;
|
||||
collection?: string;
|
||||
useGridFS?: boolean;
|
||||
readPreference?: ReadPreference;
|
||||
}
|
||||
|
||||
interface GridFSClient {
|
||||
bucket: GridFSBucket;
|
||||
store: Collection;
|
||||
db: Db;
|
||||
}
|
||||
|
||||
interface CollectionClient {
|
||||
store: Collection;
|
||||
db: Db;
|
||||
}
|
||||
|
||||
type Client = GridFSClient | CollectionClient;
|
||||
|
||||
const storeMap = new Map<string, Client>();
|
||||
const storeMap = new Map();
|
||||
|
||||
class KeyvMongoCustom extends EventEmitter {
|
||||
private opts: KeyvMongoOptions;
|
||||
public ttlSupport: boolean;
|
||||
public namespace?: string;
|
||||
|
||||
constructor(options: KeyvMongoOptions = {}) {
|
||||
constructor(url, options = {}) {
|
||||
super();
|
||||
|
||||
url = url || {};
|
||||
if (typeof url === 'string') {
|
||||
url = { url };
|
||||
}
|
||||
if (url.uri) {
|
||||
url = { url: url.uri, ...url };
|
||||
}
|
||||
|
||||
this.opts = {
|
||||
url: 'mongodb://127.0.0.1:27017',
|
||||
collection: 'keyv',
|
||||
...url,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.ttlSupport = false;
|
||||
|
||||
// Filter valid options
|
||||
const keyvMongoKeys = new Set([
|
||||
'url',
|
||||
'collection',
|
||||
'namespace',
|
||||
'serialize',
|
||||
'deserialize',
|
||||
'uri',
|
||||
'useGridFS',
|
||||
'dialect',
|
||||
]);
|
||||
this.opts = Object.fromEntries(Object.entries(this.opts).filter(([k]) => keyvMongoKeys.has(k)));
|
||||
}
|
||||
|
||||
// Helper to access the store WITHOUT storing a promise on the instance
|
||||
private async _getClient(): Promise<Client> {
|
||||
_getClient() {
|
||||
const storeKey = `${this.opts.collection}:${this.opts.useGridFS ? 'gridfs' : 'collection'}`;
|
||||
|
||||
// If we already have the store initialized, return it directly
|
||||
if (storeMap.has(storeKey)) {
|
||||
return storeMap.get(storeKey)!;
|
||||
return Promise.resolve(storeMap.get(storeKey));
|
||||
}
|
||||
|
||||
// Check mongoose connection state
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
throw new Error('Mongoose connection not ready. Ensure connectDb() is called first.');
|
||||
return Promise.reject(
|
||||
new Error('Mongoose connection not ready. Ensure connectDb() is called first.'),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const db = mongoose.connection.db as unknown as Db | undefined;
|
||||
if (!db) {
|
||||
throw new Error('MongoDB database not available');
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
const db = mongoose.connection.db;
|
||||
let client;
|
||||
|
||||
if (this.opts.useGridFS) {
|
||||
const bucket = new GridFSBucket(db, {
|
||||
@@ -79,17 +75,17 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
}
|
||||
|
||||
storeMap.set(storeKey, client);
|
||||
return client;
|
||||
return Promise.resolve(client);
|
||||
} catch (error) {
|
||||
this.emit('error', error);
|
||||
throw error;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<unknown> {
|
||||
async get(key) {
|
||||
const client = await this._getClient();
|
||||
|
||||
if (this.opts.useGridFS && this.isGridFSClient(client)) {
|
||||
if (this.opts.useGridFS) {
|
||||
await client.store.updateOne(
|
||||
{
|
||||
filename: key,
|
||||
@@ -104,7 +100,7 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
const stream = client.bucket.openDownloadStreamByName(key);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const resp: Uint8Array[] = [];
|
||||
const resp = [];
|
||||
stream.on('error', () => {
|
||||
resolve(undefined);
|
||||
});
|
||||
@@ -114,7 +110,7 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
resolve(data);
|
||||
});
|
||||
|
||||
stream.on('data', (chunk: Uint8Array) => {
|
||||
stream.on('data', (chunk) => {
|
||||
resp.push(chunk);
|
||||
});
|
||||
});
|
||||
@@ -129,7 +125,7 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
return document.value;
|
||||
}
|
||||
|
||||
async getMany(keys: string[]): Promise<unknown[]> {
|
||||
async getMany(keys) {
|
||||
const client = await this._getClient();
|
||||
|
||||
if (this.opts.useGridFS) {
|
||||
@@ -139,9 +135,9 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
}
|
||||
|
||||
const values = await Promise.allSettled(promises);
|
||||
const data: unknown[] = [];
|
||||
const data = [];
|
||||
for (const value of values) {
|
||||
data.push(value.status === 'fulfilled' ? value.value : undefined);
|
||||
data.push(value.value);
|
||||
}
|
||||
|
||||
return data;
|
||||
@@ -152,7 +148,7 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
.project({ _id: 0, value: 1, key: 1 })
|
||||
.toArray();
|
||||
|
||||
const results: unknown[] = [...keys];
|
||||
const results = [...keys];
|
||||
let i = 0;
|
||||
for (const key of keys) {
|
||||
const rowIndex = values.findIndex((row) => row.key === key);
|
||||
@@ -163,11 +159,11 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
return results;
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttl?: number): Promise<unknown> {
|
||||
async set(key, value, ttl) {
|
||||
const client = await this._getClient();
|
||||
const expiresAt = typeof ttl === 'number' ? new Date(Date.now() + ttl) : null;
|
||||
|
||||
if (this.opts.useGridFS && this.isGridFSClient(client)) {
|
||||
if (this.opts.useGridFS) {
|
||||
const stream = client.bucket.openUploadStream(key, {
|
||||
metadata: {
|
||||
expiresAt,
|
||||
@@ -190,18 +186,20 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
async delete(key) {
|
||||
if (typeof key !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = await this._getClient();
|
||||
|
||||
if (this.opts.useGridFS && this.isGridFSClient(client)) {
|
||||
if (this.opts.useGridFS) {
|
||||
try {
|
||||
const bucket = new GridFSBucket(client.db, {
|
||||
bucketName: this.opts.collection,
|
||||
});
|
||||
const files = await bucket.find({ filename: key }).toArray();
|
||||
if (files.length > 0) {
|
||||
await client.bucket.delete(files[0]._id);
|
||||
}
|
||||
await client.bucket.delete(files[0]._id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -212,10 +210,10 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
return object.deletedCount > 0;
|
||||
}
|
||||
|
||||
async deleteMany(keys: string[]): Promise<boolean> {
|
||||
async deleteMany(keys) {
|
||||
const client = await this._getClient();
|
||||
|
||||
if (this.opts.useGridFS && this.isGridFSClient(client)) {
|
||||
if (this.opts.useGridFS) {
|
||||
const bucket = new GridFSBucket(client.db, {
|
||||
bucketName: this.opts.collection,
|
||||
});
|
||||
@@ -232,17 +230,15 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
return object.deletedCount > 0;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
async clear() {
|
||||
const client = await this._getClient();
|
||||
|
||||
if (this.opts.useGridFS && this.isGridFSClient(client)) {
|
||||
if (this.opts.useGridFS) {
|
||||
try {
|
||||
await client.bucket.drop();
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
// Throw error if not "namespace not found" error
|
||||
const errorCode =
|
||||
error instanceof Error && 'code' in error ? (error as { code?: number }).code : undefined;
|
||||
if (errorCode !== 26) {
|
||||
if (!(error.code === 26)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -253,7 +249,7 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
async has(key) {
|
||||
const client = await this._getClient();
|
||||
const filter = { [this.opts.useGridFS ? 'filename' : 'key']: { $eq: key } };
|
||||
const document = await client.store.countDocuments(filter, { limit: 1 });
|
||||
@@ -261,14 +257,10 @@ class KeyvMongoCustom extends EventEmitter {
|
||||
}
|
||||
|
||||
// No-op disconnect
|
||||
async disconnect(): Promise<boolean> {
|
||||
async disconnect() {
|
||||
// This is a no-op since we don't want to close the shared mongoose connection
|
||||
return true;
|
||||
}
|
||||
|
||||
private isGridFSClient(client: Client): client is GridFSClient {
|
||||
return (client as GridFSClient).bucket != null;
|
||||
}
|
||||
}
|
||||
|
||||
const keyvMongo = new KeyvMongoCustom({
|
||||
@@ -277,4 +269,4 @@ const keyvMongo = new KeyvMongoCustom({
|
||||
|
||||
keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err));
|
||||
|
||||
export default keyvMongo;
|
||||
module.exports = keyvMongo;
|
||||
@@ -1,24 +1,26 @@
|
||||
import IoRedis from 'ioredis';
|
||||
import type { Redis, Cluster } from 'ioredis';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { createClient, createCluster } from '@keyv/redis';
|
||||
import type { RedisClientType, RedisClusterType } from '@redis/client';
|
||||
import { cacheConfig } from './cacheConfig';
|
||||
const IoRedis = require('ioredis');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { createClient, createCluster } = require('@keyv/redis');
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
|
||||
const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)) || [];
|
||||
const username = urls?.[0]?.username || cacheConfig.REDIS_USERNAME;
|
||||
const password = urls?.[0]?.password || cacheConfig.REDIS_PASSWORD;
|
||||
const GLOBAL_PREFIX_SEPARATOR = '::';
|
||||
|
||||
const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri));
|
||||
const username = urls?.[0].username || cacheConfig.REDIS_USERNAME;
|
||||
const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD;
|
||||
const ca = cacheConfig.REDIS_CA;
|
||||
|
||||
let ioredisClient: Redis | Cluster | null = null;
|
||||
/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */
|
||||
let ioredisClient = null;
|
||||
if (cacheConfig.USE_REDIS) {
|
||||
const redisOptions: Record<string, unknown> = {
|
||||
/** @type {import('ioredis').RedisOptions | import('ioredis').ClusterOptions} */
|
||||
const redisOptions = {
|
||||
username: username,
|
||||
password: password,
|
||||
tls: ca ? { ca } : undefined,
|
||||
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${cacheConfig.GLOBAL_PREFIX_SEPARATOR}`,
|
||||
keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`,
|
||||
maxListeners: cacheConfig.REDIS_MAX_LISTENERS,
|
||||
retryStrategy: (times: number) => {
|
||||
retryStrategy: (times) => {
|
||||
if (
|
||||
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
|
||||
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
|
||||
@@ -32,7 +34,7 @@ if (cacheConfig.USE_REDIS) {
|
||||
logger.info(`ioredis reconnecting... attempt ${times}, delay ${delay}ms`);
|
||||
return delay;
|
||||
},
|
||||
reconnectOnError: (err: Error) => {
|
||||
reconnectOnError: (err) => {
|
||||
const targetError = 'READONLY';
|
||||
if (err.message.includes(targetError)) {
|
||||
logger.warn('ioredis reconnecting due to READONLY error');
|
||||
@@ -47,20 +49,15 @@ if (cacheConfig.USE_REDIS) {
|
||||
|
||||
ioredisClient =
|
||||
urls.length === 1 && !cacheConfig.USE_REDIS_CLUSTER
|
||||
? new IoRedis(cacheConfig.REDIS_URI!, redisOptions)
|
||||
? new IoRedis(cacheConfig.REDIS_URI, redisOptions)
|
||||
: new IoRedis.Cluster(
|
||||
urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })),
|
||||
{
|
||||
...(cacheConfig.REDIS_USE_ALTERNATIVE_DNS_LOOKUP
|
||||
? {
|
||||
dnsLookup: (
|
||||
address: string,
|
||||
callback: (err: Error | null, address: string) => void,
|
||||
) => callback(null, address),
|
||||
}
|
||||
? { dnsLookup: (address, callback) => callback(null, address) }
|
||||
: {}),
|
||||
redisOptions,
|
||||
clusterRetryStrategy: (times: number) => {
|
||||
clusterRetryStrategy: (times) => {
|
||||
if (
|
||||
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
|
||||
times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
|
||||
@@ -90,7 +87,7 @@ if (cacheConfig.USE_REDIS) {
|
||||
logger.info('ioredis client ready');
|
||||
});
|
||||
|
||||
ioredisClient.on('reconnecting', (delay: number) => {
|
||||
ioredisClient.on('reconnecting', (delay) => {
|
||||
logger.info(`ioredis client reconnecting in ${delay}ms`);
|
||||
});
|
||||
|
||||
@@ -99,7 +96,7 @@ if (cacheConfig.USE_REDIS) {
|
||||
});
|
||||
|
||||
/** Ping Interval to keep the Redis server connection alive (if enabled) */
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let pingInterval = null;
|
||||
const clearPingInterval = () => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
@@ -120,20 +117,22 @@ if (cacheConfig.USE_REDIS) {
|
||||
}
|
||||
}
|
||||
|
||||
let keyvRedisClient: RedisClientType | RedisClusterType | null = null;
|
||||
/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */
|
||||
let keyvRedisClient = null;
|
||||
if (cacheConfig.USE_REDIS) {
|
||||
/**
|
||||
* ** WARNING ** Keyv Redis client does not support Prefix like ioredis above.
|
||||
* The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js
|
||||
* @type {import('@keyv/redis').RedisClientOptions | import('@keyv/redis').RedisClusterOptions}
|
||||
*/
|
||||
const redisOptions: Record<string, unknown> = {
|
||||
const redisOptions = {
|
||||
username,
|
||||
password,
|
||||
socket: {
|
||||
tls: ca != null,
|
||||
ca,
|
||||
connectTimeout: cacheConfig.REDIS_CONNECT_TIMEOUT,
|
||||
reconnectStrategy: (retries: number) => {
|
||||
reconnectStrategy: (retries) => {
|
||||
if (
|
||||
cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 &&
|
||||
retries > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS
|
||||
@@ -149,9 +148,6 @@ if (cacheConfig.USE_REDIS) {
|
||||
},
|
||||
},
|
||||
disableOfflineQueue: !cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE,
|
||||
...(cacheConfig.REDIS_PING_INTERVAL > 0
|
||||
? { pingInterval: cacheConfig.REDIS_PING_INTERVAL * 1000 }
|
||||
: {}),
|
||||
};
|
||||
|
||||
keyvRedisClient =
|
||||
@@ -188,6 +184,27 @@ if (cacheConfig.USE_REDIS) {
|
||||
logger.error('@keyv/redis initial connection failed:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
/** Ping Interval to keep the Redis server connection alive (if enabled) */
|
||||
let pingInterval = null;
|
||||
const clearPingInterval = () => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (cacheConfig.REDIS_PING_INTERVAL > 0) {
|
||||
pingInterval = setInterval(() => {
|
||||
if (keyvRedisClient && keyvRedisClient.isReady) {
|
||||
keyvRedisClient.ping().catch((err) => {
|
||||
logger.error('@keyv/redis ping failed:', err);
|
||||
});
|
||||
}
|
||||
}, cacheConfig.REDIS_PING_INTERVAL * 1000);
|
||||
keyvRedisClient.on('disconnect', clearPingInterval);
|
||||
keyvRedisClient.on('end', clearPingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
export { ioredisClient, keyvRedisClient };
|
||||
module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR };
|
||||
@@ -29,64 +29,12 @@ class MeiliSearchClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes documents from MeiliSearch index that are missing the user field
|
||||
* @param {import('meilisearch').Index} index - MeiliSearch index instance
|
||||
* @param {string} indexName - Name of the index for logging
|
||||
* @returns {Promise<number>} - Number of documents deleted
|
||||
*/
|
||||
async function deleteDocumentsWithoutUserField(index, indexName) {
|
||||
let deletedCount = 0;
|
||||
let offset = 0;
|
||||
const batchSize = 1000;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const searchResult = await index.search('', {
|
||||
limit: batchSize,
|
||||
offset: offset,
|
||||
});
|
||||
|
||||
if (searchResult.hits.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id);
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
logger.info(
|
||||
`[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`,
|
||||
);
|
||||
await index.deleteDocuments(idsToDelete);
|
||||
deletedCount += idsToDelete.length;
|
||||
}
|
||||
|
||||
if (searchResult.hits.length < batchSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += batchSize;
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures indexes have proper filterable attributes configured and checks if documents have user field
|
||||
* @param {MeiliSearch} client - MeiliSearch client instance
|
||||
* @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done
|
||||
* @returns {Promise<boolean>} - true if configuration was updated or re-sync is needed
|
||||
*/
|
||||
async function ensureFilterableAttributes(client) {
|
||||
let settingsUpdated = false;
|
||||
let hasOrphanedDocs = false;
|
||||
|
||||
try {
|
||||
// Check and update messages index
|
||||
try {
|
||||
@@ -99,17 +47,16 @@ async function ensureFilterableAttributes(client) {
|
||||
filterableAttributes: ['user'],
|
||||
});
|
||||
logger.info('[indexSync] Messages index configured for user filtering');
|
||||
settingsUpdated = true;
|
||||
logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if existing documents have user field indexed
|
||||
try {
|
||||
const searchResult = await messagesIndex.search('', { limit: 1 });
|
||||
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
|
||||
logger.info(
|
||||
'[indexSync] Existing messages missing user field, will clean up orphaned documents...',
|
||||
);
|
||||
hasOrphanedDocs = true;
|
||||
logger.info('[indexSync] Existing messages missing user field, re-sync needed');
|
||||
return true;
|
||||
}
|
||||
} catch (searchError) {
|
||||
logger.debug('[indexSync] Could not check message documents:', searchError.message);
|
||||
@@ -131,17 +78,16 @@ async function ensureFilterableAttributes(client) {
|
||||
filterableAttributes: ['user'],
|
||||
});
|
||||
logger.info('[indexSync] Convos index configured for user filtering');
|
||||
settingsUpdated = true;
|
||||
logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if existing documents have user field indexed
|
||||
try {
|
||||
const searchResult = await convosIndex.search('', { limit: 1 });
|
||||
if (searchResult.hits.length > 0 && !searchResult.hits[0].user) {
|
||||
logger.info(
|
||||
'[indexSync] Existing conversations missing user field, will clean up orphaned documents...',
|
||||
);
|
||||
hasOrphanedDocs = true;
|
||||
logger.info('[indexSync] Existing conversations missing user field, re-sync needed');
|
||||
return true;
|
||||
}
|
||||
} catch (searchError) {
|
||||
logger.debug('[indexSync] Could not check conversation documents:', searchError.message);
|
||||
@@ -151,143 +97,101 @@ async function ensureFilterableAttributes(client) {
|
||||
logger.warn('[indexSync] Could not check/update convos index settings:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// If either index has orphaned documents, clean them up (but don't force resync)
|
||||
if (hasOrphanedDocs) {
|
||||
try {
|
||||
const messagesIndex = client.index('messages');
|
||||
await deleteDocumentsWithoutUserField(messagesIndex, 'messages');
|
||||
} catch (error) {
|
||||
logger.debug('[indexSync] Could not clean up messages:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const convosIndex = client.index('convos');
|
||||
await deleteDocumentsWithoutUserField(convosIndex, 'convos');
|
||||
} catch (error) {
|
||||
logger.debug('[indexSync] Could not clean up convos:', error.message);
|
||||
}
|
||||
|
||||
logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.');
|
||||
}
|
||||
|
||||
if (settingsUpdated) {
|
||||
logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[indexSync] Error ensuring filterable attributes:', error);
|
||||
}
|
||||
|
||||
return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs };
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual sync operations for messages and conversations
|
||||
* @param {FlowStateManager} flowManager - Flow state manager instance
|
||||
* @param {string} flowId - Flow identifier
|
||||
* @param {string} flowType - Flow type
|
||||
*/
|
||||
async function performSync(flowManager, flowId, flowType) {
|
||||
try {
|
||||
const client = MeiliSearchClient.getInstance();
|
||||
async function performSync() {
|
||||
const client = MeiliSearchClient.getInstance();
|
||||
|
||||
const { status } = await client.health();
|
||||
if (status !== 'available') {
|
||||
throw new Error('Meilisearch not available');
|
||||
}
|
||||
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||
return { messagesSync: false, convosSync: false };
|
||||
}
|
||||
|
||||
/** Ensures indexes have proper filterable attributes configured */
|
||||
const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } =
|
||||
await ensureFilterableAttributes(client);
|
||||
|
||||
let messagesSync = false;
|
||||
let convosSync = false;
|
||||
|
||||
// Only reset flags if settings were actually updated (not just for orphaned doc cleanup)
|
||||
if (settingsUpdated) {
|
||||
logger.info(
|
||||
'[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...',
|
||||
);
|
||||
|
||||
// Reset sync flags to force full re-sync
|
||||
await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } });
|
||||
await Conversation.collection.updateMany(
|
||||
{ _meiliIndex: true },
|
||||
{ $set: { _meiliIndex: false } },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to sync messages
|
||||
const messageProgress = await Message.getSyncProgress();
|
||||
if (!messageProgress.isComplete || settingsUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
// Check if we should do a full sync or incremental
|
||||
const messageCount = await Message.countDocuments();
|
||||
const messagesIndexed = messageProgress.totalProcessed;
|
||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||
|
||||
if (messageCount - messagesIndexed > syncThreshold) {
|
||||
logger.info('[indexSync] Starting full message sync due to large difference');
|
||||
await Message.syncWithMeili();
|
||||
messagesSync = true;
|
||||
} else if (messageCount !== messagesIndexed) {
|
||||
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
|
||||
await Message.syncWithMeili();
|
||||
messagesSync = true;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to sync conversations
|
||||
const convoProgress = await Conversation.getSyncProgress();
|
||||
if (!convoProgress.isComplete || settingsUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
const convoCount = await Conversation.countDocuments();
|
||||
const convosIndexed = convoProgress.totalProcessed;
|
||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||
|
||||
if (convoCount - convosIndexed > syncThreshold) {
|
||||
logger.info('[indexSync] Starting full conversation sync due to large difference');
|
||||
await Conversation.syncWithMeili();
|
||||
convosSync = true;
|
||||
} else if (convoCount !== convosIndexed) {
|
||||
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
|
||||
await Conversation.syncWithMeili();
|
||||
convosSync = true;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { messagesSync, convosSync };
|
||||
} finally {
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping cleanup...');
|
||||
} else if (flowManager && flowId && flowType) {
|
||||
try {
|
||||
await flowManager.deleteFlow(flowId, flowType);
|
||||
logger.debug('[indexSync] Flow state cleaned up');
|
||||
} catch (cleanupErr) {
|
||||
logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message);
|
||||
}
|
||||
}
|
||||
const { status } = await client.health();
|
||||
if (status !== 'available') {
|
||||
throw new Error('Meilisearch not available');
|
||||
}
|
||||
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||
return { messagesSync: false, convosSync: false };
|
||||
}
|
||||
|
||||
/** Ensures indexes have proper filterable attributes configured */
|
||||
const configUpdated = await ensureFilterableAttributes(client);
|
||||
|
||||
let messagesSync = false;
|
||||
let convosSync = false;
|
||||
|
||||
// If configuration was just updated or documents are missing user field, force a full re-sync
|
||||
if (configUpdated) {
|
||||
logger.info('[indexSync] Forcing full re-sync to ensure user field is properly indexed...');
|
||||
|
||||
// Reset sync flags to force full re-sync
|
||||
await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } });
|
||||
await Conversation.collection.updateMany(
|
||||
{ _meiliIndex: true },
|
||||
{ $set: { _meiliIndex: false } },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to sync messages
|
||||
const messageProgress = await Message.getSyncProgress();
|
||||
if (!messageProgress.isComplete || configUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
// Check if we should do a full sync or incremental
|
||||
const messageCount = await Message.countDocuments();
|
||||
const messagesIndexed = messageProgress.totalProcessed;
|
||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||
|
||||
if (messageCount - messagesIndexed > syncThreshold) {
|
||||
logger.info('[indexSync] Starting full message sync due to large difference');
|
||||
await Message.syncWithMeili();
|
||||
messagesSync = true;
|
||||
} else if (messageCount !== messagesIndexed) {
|
||||
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
|
||||
await Message.syncWithMeili();
|
||||
messagesSync = true;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to sync conversations
|
||||
const convoProgress = await Conversation.getSyncProgress();
|
||||
if (!convoProgress.isComplete || configUpdated) {
|
||||
logger.info(
|
||||
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
||||
);
|
||||
|
||||
const convoCount = await Conversation.countDocuments();
|
||||
const convosIndexed = convoProgress.totalProcessed;
|
||||
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||
|
||||
if (convoCount - convosIndexed > syncThreshold) {
|
||||
logger.info('[indexSync] Starting full conversation sync due to large difference');
|
||||
await Conversation.syncWithMeili();
|
||||
convosSync = true;
|
||||
} else if (convoCount !== convosIndexed) {
|
||||
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
|
||||
await Conversation.syncWithMeili();
|
||||
convosSync = true;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { messagesSync, convosSync };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,26 +204,24 @@ async function indexSync() {
|
||||
|
||||
logger.info('[indexSync] Starting index synchronization check...');
|
||||
|
||||
// Get or create FlowStateManager instance
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
if (!flowsCache) {
|
||||
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
|
||||
return await performSync(null, null, null);
|
||||
}
|
||||
|
||||
const flowManager = new FlowStateManager(flowsCache, {
|
||||
ttl: 60000 * 10, // 10 minutes TTL for sync operations
|
||||
});
|
||||
|
||||
// Use a unique flow ID for the sync operation
|
||||
const flowId = 'meili-index-sync';
|
||||
const flowType = 'MEILI_SYNC';
|
||||
|
||||
try {
|
||||
// Get or create FlowStateManager instance
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
if (!flowsCache) {
|
||||
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
|
||||
return await performSync();
|
||||
}
|
||||
|
||||
const flowManager = new FlowStateManager(flowsCache, {
|
||||
ttl: 60000 * 10, // 10 minutes TTL for sync operations
|
||||
});
|
||||
|
||||
// Use a unique flow ID for the sync operation
|
||||
const flowId = 'meili-index-sync';
|
||||
const flowType = 'MEILI_SYNC';
|
||||
|
||||
// This will only execute the handler if no other instance is running the sync
|
||||
const result = await flowManager.createFlowWithHandler(flowId, flowType, () =>
|
||||
performSync(flowManager, flowId, flowType),
|
||||
);
|
||||
const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync);
|
||||
|
||||
if (result.messagesSync || result.convosSync) {
|
||||
logger.info('[indexSync] Sync completed successfully');
|
||||
|
||||
@@ -62,38 +62,25 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const { model, ...model_parameters } = _m;
|
||||
const modelSpecs = req.config?.modelSpecs?.list;
|
||||
/** @type {TModelSpec | null} */
|
||||
let modelSpec = null;
|
||||
if (spec != null && spec !== '') {
|
||||
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
|
||||
}
|
||||
/** @type {TEphemeralAgent | null} */
|
||||
const ephemeralAgent = req.body.ephemeralAgent;
|
||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||
const userId = req.user?.id; // note: userId cannot be undefined at runtime
|
||||
if (modelSpec?.mcpServers) {
|
||||
for (const mcpServer of modelSpec.mcpServers) {
|
||||
mcpServers.add(mcpServer);
|
||||
}
|
||||
}
|
||||
/** @type {string[]} */
|
||||
const tools = [];
|
||||
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
|
||||
if (ephemeralAgent?.execute_code === true) {
|
||||
tools.push(Tools.execute_code);
|
||||
}
|
||||
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
|
||||
if (ephemeralAgent?.file_search === true) {
|
||||
tools.push(Tools.file_search);
|
||||
}
|
||||
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
|
||||
if (ephemeralAgent?.web_search === true) {
|
||||
tools.push(Tools.web_search);
|
||||
}
|
||||
|
||||
@@ -103,7 +90,7 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
if (addedServers.has(mcpServer)) {
|
||||
continue;
|
||||
}
|
||||
const serverTools = await getMCPServerTools(userId, mcpServer);
|
||||
const serverTools = await getMCPServerTools(mcpServer);
|
||||
if (!serverTools) {
|
||||
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
|
||||
addedServers.add(mcpServer);
|
||||
@@ -135,18 +122,17 @@ const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_paramet
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {string} params.spec
|
||||
* @param {string} params.agent_id
|
||||
* @param {string} params.endpoint
|
||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||
*/
|
||||
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
|
||||
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
if (!agent_id) {
|
||||
return null;
|
||||
}
|
||||
if (agent_id === EPHEMERAL_AGENT_ID) {
|
||||
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
|
||||
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
||||
}
|
||||
const agent = await getAgent({
|
||||
id: agent_id,
|
||||
|
||||
@@ -1931,7 +1931,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return tools for each server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return { tool1_mcp_server1: {} };
|
||||
} else if (server === 'server2') {
|
||||
@@ -2125,7 +2125,7 @@ describe('models/Agent', () => {
|
||||
getCachedTools.mockResolvedValue(availableTools);
|
||||
|
||||
// Mock getMCPServerTools to return all tools for server1
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
return availableTools; // All 100 tools belong to server1
|
||||
}
|
||||
@@ -2674,7 +2674,7 @@ describe('models/Agent', () => {
|
||||
});
|
||||
|
||||
// Mock getMCPServerTools to return only tools matching the server
|
||||
getMCPServerTools.mockImplementation(async (_userId, server) => {
|
||||
getMCPServerTools.mockImplementation(async (server) => {
|
||||
if (server === 'server1') {
|
||||
// Only return tool that correctly matches server1 format
|
||||
return { tool_mcp_server1: {} };
|
||||
|
||||
275
api/models/tx.js
275
api/models/tx.js
@@ -1,4 +1,4 @@
|
||||
const { matchModelName, findMatchingPattern } = require('@librechat/api');
|
||||
const { matchModelName } = require('@librechat/api');
|
||||
const defaultRate = 6;
|
||||
|
||||
/**
|
||||
@@ -6,58 +6,44 @@ const defaultRate = 6;
|
||||
* source: https://aws.amazon.com/bedrock/pricing/
|
||||
* */
|
||||
const bedrockValues = {
|
||||
// Basic llama2 patterns (base defaults to smallest variant)
|
||||
llama2: { prompt: 0.75, completion: 1.0 },
|
||||
'llama-2': { prompt: 0.75, completion: 1.0 },
|
||||
// Basic llama2 patterns
|
||||
'llama2-13b': { prompt: 0.75, completion: 1.0 },
|
||||
'llama2:13b': { prompt: 0.75, completion: 1.0 },
|
||||
'llama2:70b': { prompt: 1.95, completion: 2.56 },
|
||||
'llama2-70b': { prompt: 1.95, completion: 2.56 },
|
||||
|
||||
// Basic llama3 patterns (base defaults to smallest variant)
|
||||
llama3: { prompt: 0.3, completion: 0.6 },
|
||||
'llama-3': { prompt: 0.3, completion: 0.6 },
|
||||
// Basic llama3 patterns
|
||||
'llama3-8b': { prompt: 0.3, completion: 0.6 },
|
||||
'llama3:8b': { prompt: 0.3, completion: 0.6 },
|
||||
'llama3-70b': { prompt: 2.65, completion: 3.5 },
|
||||
'llama3:70b': { prompt: 2.65, completion: 3.5 },
|
||||
|
||||
// llama3-x-Nb pattern (base defaults to smallest variant)
|
||||
'llama3-1': { prompt: 0.22, completion: 0.22 },
|
||||
// llama3-x-Nb pattern
|
||||
'llama3-1-8b': { prompt: 0.22, completion: 0.22 },
|
||||
'llama3-1-70b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama3-1-405b': { prompt: 2.4, completion: 2.4 },
|
||||
'llama3-2': { prompt: 0.1, completion: 0.1 },
|
||||
'llama3-2-1b': { prompt: 0.1, completion: 0.1 },
|
||||
'llama3-2-3b': { prompt: 0.15, completion: 0.15 },
|
||||
'llama3-2-11b': { prompt: 0.16, completion: 0.16 },
|
||||
'llama3-2-90b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama3-3': { prompt: 2.65, completion: 3.5 },
|
||||
'llama3-3-70b': { prompt: 2.65, completion: 3.5 },
|
||||
|
||||
// llama3.x:Nb pattern (base defaults to smallest variant)
|
||||
'llama3.1': { prompt: 0.22, completion: 0.22 },
|
||||
// llama3.x:Nb pattern
|
||||
'llama3.1:8b': { prompt: 0.22, completion: 0.22 },
|
||||
'llama3.1:70b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama3.1:405b': { prompt: 2.4, completion: 2.4 },
|
||||
'llama3.2': { prompt: 0.1, completion: 0.1 },
|
||||
'llama3.2:1b': { prompt: 0.1, completion: 0.1 },
|
||||
'llama3.2:3b': { prompt: 0.15, completion: 0.15 },
|
||||
'llama3.2:11b': { prompt: 0.16, completion: 0.16 },
|
||||
'llama3.2:90b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama3.3': { prompt: 2.65, completion: 3.5 },
|
||||
'llama3.3:70b': { prompt: 2.65, completion: 3.5 },
|
||||
|
||||
// llama-3.x-Nb pattern (base defaults to smallest variant)
|
||||
'llama-3.1': { prompt: 0.22, completion: 0.22 },
|
||||
// llama-3.x-Nb pattern
|
||||
'llama-3.1-8b': { prompt: 0.22, completion: 0.22 },
|
||||
'llama-3.1-70b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama-3.1-405b': { prompt: 2.4, completion: 2.4 },
|
||||
'llama-3.2': { prompt: 0.1, completion: 0.1 },
|
||||
'llama-3.2-1b': { prompt: 0.1, completion: 0.1 },
|
||||
'llama-3.2-3b': { prompt: 0.15, completion: 0.15 },
|
||||
'llama-3.2-11b': { prompt: 0.16, completion: 0.16 },
|
||||
'llama-3.2-90b': { prompt: 0.72, completion: 0.72 },
|
||||
'llama-3.3': { prompt: 2.65, completion: 3.5 },
|
||||
'llama-3.3-70b': { prompt: 2.65, completion: 3.5 },
|
||||
'mistral-7b': { prompt: 0.15, completion: 0.2 },
|
||||
'mistral-small': { prompt: 0.15, completion: 0.2 },
|
||||
@@ -66,19 +52,15 @@ const bedrockValues = {
|
||||
'mistral-large-2407': { prompt: 3.0, completion: 9.0 },
|
||||
'command-text': { prompt: 1.5, completion: 2.0 },
|
||||
'command-light': { prompt: 0.3, completion: 0.6 },
|
||||
// AI21 models
|
||||
'j2-mid': { prompt: 12.5, completion: 12.5 },
|
||||
'j2-ultra': { prompt: 18.8, completion: 18.8 },
|
||||
'jamba-instruct': { prompt: 0.5, completion: 0.7 },
|
||||
// Amazon Titan models
|
||||
'titan-text-lite': { prompt: 0.15, completion: 0.2 },
|
||||
'titan-text-express': { prompt: 0.2, completion: 0.6 },
|
||||
'titan-text-premier': { prompt: 0.5, completion: 1.5 },
|
||||
// Amazon Nova models
|
||||
'nova-micro': { prompt: 0.035, completion: 0.14 },
|
||||
'nova-lite': { prompt: 0.06, completion: 0.24 },
|
||||
'nova-pro': { prompt: 0.8, completion: 3.2 },
|
||||
'nova-premier': { prompt: 2.5, completion: 12.5 },
|
||||
'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 },
|
||||
'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 },
|
||||
'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 },
|
||||
'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 },
|
||||
'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 },
|
||||
'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 },
|
||||
'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 },
|
||||
'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 },
|
||||
'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 },
|
||||
'deepseek.r1': { prompt: 1.35, completion: 5.4 },
|
||||
};
|
||||
|
||||
@@ -89,136 +71,89 @@ const bedrockValues = {
|
||||
*/
|
||||
const tokenValues = Object.assign(
|
||||
{
|
||||
// Legacy token size mappings (generic patterns - check LAST)
|
||||
'8k': { prompt: 30, completion: 60 },
|
||||
'32k': { prompt: 60, completion: 120 },
|
||||
'4k': { prompt: 1.5, completion: 2 },
|
||||
'16k': { prompt: 3, completion: 4 },
|
||||
// Generic fallback patterns (check LAST)
|
||||
'claude-': { prompt: 0.8, completion: 2.4 },
|
||||
deepseek: { prompt: 0.28, completion: 0.42 },
|
||||
command: { prompt: 0.38, completion: 0.38 },
|
||||
gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
|
||||
gemini: { prompt: 0.5, completion: 1.5 },
|
||||
'gpt-oss': { prompt: 0.05, completion: 0.2 },
|
||||
// Specific model variants (check FIRST - more specific patterns at end)
|
||||
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
|
||||
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
|
||||
'gpt-4-1106': { prompt: 10, completion: 30 },
|
||||
'gpt-4.1': { prompt: 2, completion: 8 },
|
||||
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
|
||||
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
|
||||
'gpt-4.5': { prompt: 75, completion: 150 },
|
||||
'gpt-4o': { prompt: 2.5, completion: 10 },
|
||||
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
|
||||
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
||||
'gpt-5': { prompt: 1.25, completion: 10 },
|
||||
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
||||
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
||||
'gpt-5-pro': { prompt: 15, completion: 120 },
|
||||
o1: { prompt: 15, completion: 60 },
|
||||
'o4-mini': { prompt: 1.1, completion: 4.4 },
|
||||
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
||||
o3: { prompt: 2, completion: 8 },
|
||||
'o1-mini': { prompt: 1.1, completion: 4.4 },
|
||||
'o1-preview': { prompt: 15, completion: 60 },
|
||||
o3: { prompt: 2, completion: 8 },
|
||||
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
||||
'o4-mini': { prompt: 1.1, completion: 4.4 },
|
||||
'claude-instant': { prompt: 0.8, completion: 2.4 },
|
||||
'claude-2': { prompt: 8, completion: 24 },
|
||||
'claude-2.1': { prompt: 8, completion: 24 },
|
||||
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
|
||||
'claude-3-sonnet': { prompt: 3, completion: 15 },
|
||||
o1: { prompt: 15, completion: 60 },
|
||||
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
|
||||
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
|
||||
'gpt-4.1': { prompt: 2, completion: 8 },
|
||||
'gpt-4.5': { prompt: 75, completion: 150 },
|
||||
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
|
||||
'gpt-5': { prompt: 1.25, completion: 10 },
|
||||
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
||||
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
||||
'gpt-4o': { prompt: 2.5, completion: 10 },
|
||||
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
|
||||
'gpt-4-1106': { prompt: 10, completion: 30 },
|
||||
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
|
||||
'claude-3-opus': { prompt: 15, completion: 75 },
|
||||
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
|
||||
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
|
||||
'claude-3-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3.5-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3-7-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-haiku-4-5': { prompt: 1, completion: 5 },
|
||||
'claude-opus-4': { prompt: 15, completion: 75 },
|
||||
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
|
||||
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
|
||||
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
|
||||
'claude-sonnet-4': { prompt: 3, completion: 15 },
|
||||
'command-r': { prompt: 0.5, completion: 1.5 },
|
||||
'claude-opus-4': { prompt: 15, completion: 75 },
|
||||
'claude-2.1': { prompt: 8, completion: 24 },
|
||||
'claude-2': { prompt: 8, completion: 24 },
|
||||
'claude-instant': { prompt: 0.8, completion: 2.4 },
|
||||
'claude-': { prompt: 0.8, completion: 2.4 },
|
||||
'command-r-plus': { prompt: 3, completion: 15 },
|
||||
'command-text': { prompt: 1.5, completion: 2.0 },
|
||||
'command-r': { prompt: 0.5, completion: 1.5 },
|
||||
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
|
||||
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
|
||||
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
|
||||
'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing)
|
||||
'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
|
||||
'gemma-3-27b': { prompt: 0.09, completion: 0.16 },
|
||||
'gemini-1.5': { prompt: 2.5, completion: 10 },
|
||||
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
||||
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
||||
'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing)
|
||||
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
|
||||
deepseek: { prompt: 0.28, completion: 0.42 },
|
||||
/* cohere doesn't have rates for the older command models,
|
||||
so this was from https://artificialanalysis.ai/models/command-light/providers */
|
||||
command: { prompt: 0.38, completion: 0.38 },
|
||||
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
|
||||
'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing)
|
||||
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
||||
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
||||
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
|
||||
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
||||
'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 },
|
||||
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
||||
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
||||
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
||||
'gemini-1.5': { prompt: 2.5, completion: 10 },
|
||||
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
||||
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||
'grok-vision-beta': { prompt: 5.0, completion: 15.0 },
|
||||
'grok-2': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-vision': { prompt: 2.0, completion: 10.0 },
|
||||
gemini: { prompt: 0.5, completion: 1.5 },
|
||||
'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-3': { prompt: 3.0, completion: 15.0 },
|
||||
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||
'grok-2-vision': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-vision-beta': { prompt: 5.0, completion: 15.0 },
|
||||
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-2': { prompt: 2.0, completion: 10.0 },
|
||||
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
|
||||
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
|
||||
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
|
||||
'grok-3': { prompt: 3.0, completion: 15.0 },
|
||||
'grok-4': { prompt: 3.0, completion: 15.0 },
|
||||
codestral: { prompt: 0.3, completion: 0.9 },
|
||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
||||
'mistral-nemo': { prompt: 0.15, completion: 0.15 },
|
||||
'mistral-saba': { prompt: 0.2, completion: 0.6 },
|
||||
'pixtral-large': { prompt: 2.0, completion: 6.0 },
|
||||
'grok-beta': { prompt: 5.0, completion: 15.0 },
|
||||
'mistral-large': { prompt: 2.0, completion: 6.0 },
|
||||
'mixtral-8x22b': { prompt: 0.65, completion: 0.65 },
|
||||
kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing)
|
||||
// GPT-OSS models (specific sizes)
|
||||
'gpt-oss:20b': { prompt: 0.05, completion: 0.2 },
|
||||
'pixtral-large': { prompt: 2.0, completion: 6.0 },
|
||||
'mistral-saba': { prompt: 0.2, completion: 0.6 },
|
||||
codestral: { prompt: 0.3, completion: 0.9 },
|
||||
'ministral-8b': { prompt: 0.1, completion: 0.1 },
|
||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||
// GPT-OSS models
|
||||
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
|
||||
'gpt-oss:120b': { prompt: 0.15, completion: 0.6 },
|
||||
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
|
||||
// GLM models (Zhipu AI) - general to specific
|
||||
glm4: { prompt: 0.1, completion: 0.1 },
|
||||
'glm-4': { prompt: 0.1, completion: 0.1 },
|
||||
'glm-4-32b': { prompt: 0.1, completion: 0.1 },
|
||||
'glm-4.5': { prompt: 0.35, completion: 1.55 },
|
||||
'glm-4.5-air': { prompt: 0.14, completion: 0.86 },
|
||||
'glm-4.5v': { prompt: 0.6, completion: 1.8 },
|
||||
'glm-4.6': { prompt: 0.5, completion: 1.75 },
|
||||
// Qwen models
|
||||
qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing)
|
||||
'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern
|
||||
'qwen-turbo': { prompt: 0.05, completion: 0.2 },
|
||||
'qwen-plus': { prompt: 0.4, completion: 1.2 },
|
||||
'qwen-max': { prompt: 1.6, completion: 6.4 },
|
||||
'qwq-32b': { prompt: 0.15, completion: 0.4 },
|
||||
// Qwen3 models
|
||||
qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing)
|
||||
'qwen3-8b': { prompt: 0.035, completion: 0.138 },
|
||||
'qwen3-14b': { prompt: 0.05, completion: 0.22 },
|
||||
'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 },
|
||||
'qwen3-32b': { prompt: 0.05, completion: 0.2 },
|
||||
'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 },
|
||||
// Qwen3 VL (Vision-Language) models
|
||||
'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 },
|
||||
'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 },
|
||||
'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 },
|
||||
'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 },
|
||||
// Qwen3 specialized models
|
||||
'qwen3-max': { prompt: 1.2, completion: 6 },
|
||||
'qwen3-coder': { prompt: 0.22, completion: 0.95 },
|
||||
'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 },
|
||||
'qwen3-coder-plus': { prompt: 1, completion: 5 },
|
||||
'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 },
|
||||
'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 },
|
||||
},
|
||||
bedrockValues,
|
||||
);
|
||||
@@ -249,39 +184,67 @@ const cacheTokenValues = {
|
||||
* @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found.
|
||||
*/
|
||||
const getValueKey = (model, endpoint) => {
|
||||
if (!model || typeof model !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use findMatchingPattern directly against tokenValues for efficient lookup
|
||||
if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) {
|
||||
const matchedKey = findMatchingPattern(model, tokenValues);
|
||||
if (matchedKey) {
|
||||
return matchedKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use matchModelName for edge cases and legacy handling
|
||||
const modelName = matchModelName(model, endpoint);
|
||||
if (!modelName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Legacy token size mappings and aliases for older models
|
||||
if (modelName.includes('gpt-3.5-turbo-16k')) {
|
||||
return '16k';
|
||||
} else if (modelName.includes('gpt-3.5-turbo-0125')) {
|
||||
return 'gpt-3.5-turbo-0125';
|
||||
} else if (modelName.includes('gpt-3.5-turbo-1106')) {
|
||||
return 'gpt-3.5-turbo-1106';
|
||||
} else if (modelName.includes('gpt-3.5')) {
|
||||
return '4k';
|
||||
} else if (modelName.includes('o4-mini')) {
|
||||
return 'o4-mini';
|
||||
} else if (modelName.includes('o4')) {
|
||||
return 'o4';
|
||||
} else if (modelName.includes('o3-mini')) {
|
||||
return 'o3-mini';
|
||||
} else if (modelName.includes('o3')) {
|
||||
return 'o3';
|
||||
} else if (modelName.includes('o1-preview')) {
|
||||
return 'o1-preview';
|
||||
} else if (modelName.includes('o1-mini')) {
|
||||
return 'o1-mini';
|
||||
} else if (modelName.includes('o1')) {
|
||||
return 'o1';
|
||||
} else if (modelName.includes('gpt-4.5')) {
|
||||
return 'gpt-4.5';
|
||||
} else if (modelName.includes('gpt-4.1-nano')) {
|
||||
return 'gpt-4.1-nano';
|
||||
} else if (modelName.includes('gpt-4.1-mini')) {
|
||||
return 'gpt-4.1-mini';
|
||||
} else if (modelName.includes('gpt-4.1')) {
|
||||
return 'gpt-4.1';
|
||||
} else if (modelName.includes('gpt-4o-2024-05-13')) {
|
||||
return 'gpt-4o-2024-05-13';
|
||||
} else if (modelName.includes('gpt-5-nano')) {
|
||||
return 'gpt-5-nano';
|
||||
} else if (modelName.includes('gpt-5-mini')) {
|
||||
return 'gpt-5-mini';
|
||||
} else if (modelName.includes('gpt-5')) {
|
||||
return 'gpt-5';
|
||||
} else if (modelName.includes('gpt-4o-mini')) {
|
||||
return 'gpt-4o-mini';
|
||||
} else if (modelName.includes('gpt-4o')) {
|
||||
return 'gpt-4o';
|
||||
} else if (modelName.includes('gpt-4-vision')) {
|
||||
return 'gpt-4-1106'; // Alias for gpt-4-vision
|
||||
return 'gpt-4-1106';
|
||||
} else if (modelName.includes('gpt-4-1106')) {
|
||||
return 'gpt-4-1106';
|
||||
} else if (modelName.includes('gpt-4-0125')) {
|
||||
return 'gpt-4-1106'; // Alias for gpt-4-0125
|
||||
return 'gpt-4-1106';
|
||||
} else if (modelName.includes('gpt-4-turbo')) {
|
||||
return 'gpt-4-1106'; // Alias for gpt-4-turbo
|
||||
return 'gpt-4-1106';
|
||||
} else if (modelName.includes('gpt-4-32k')) {
|
||||
return '32k';
|
||||
} else if (modelName.includes('gpt-4')) {
|
||||
return '8k';
|
||||
} else if (tokenValues[modelName]) {
|
||||
return modelName;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { maxTokensMap } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
defaultRate,
|
||||
@@ -114,14 +113,6 @@ describe('getValueKey', () => {
|
||||
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
|
||||
});
|
||||
|
||||
it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => {
|
||||
expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
|
||||
expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro');
|
||||
expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro');
|
||||
expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
|
||||
expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro');
|
||||
});
|
||||
|
||||
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
|
||||
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
|
||||
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
|
||||
@@ -193,16 +184,6 @@ describe('getValueKey', () => {
|
||||
expect(getValueKey('claude-3.5-haiku-turbo')).toBe('claude-3.5-haiku');
|
||||
expect(getValueKey('claude-3.5-haiku-0125')).toBe('claude-3.5-haiku');
|
||||
});
|
||||
|
||||
it('should return expected value keys for "gpt-oss" models', () => {
|
||||
expect(getValueKey('openai/gpt-oss-120b')).toBe('gpt-oss-120b');
|
||||
expect(getValueKey('openai/gpt-oss:120b')).toBe('gpt-oss:120b');
|
||||
expect(getValueKey('openai/gpt-oss-570b')).toBe('gpt-oss');
|
||||
expect(getValueKey('gpt-oss-570b')).toBe('gpt-oss');
|
||||
expect(getValueKey('groq/gpt-oss-1080b')).toBe('gpt-oss');
|
||||
expect(getValueKey('gpt-oss-20b')).toBe('gpt-oss-20b');
|
||||
expect(getValueKey('oai/gpt-oss:20b')).toBe('gpt-oss:20b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMultiplier', () => {
|
||||
@@ -297,20 +278,6 @@ describe('getMultiplier', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-5-pro', () => {
|
||||
const valueKey = getValueKey('gpt-5-pro-2025-01-30');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt);
|
||||
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-pro'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['gpt-5-pro'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe(
|
||||
tokenValues['gpt-5-pro'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the correct multiplier for gpt-4o', () => {
|
||||
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||
@@ -427,18 +394,6 @@ describe('getMultiplier', () => {
|
||||
expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct multipliers for GLM models', () => {
|
||||
const models = ['glm-4.6', 'glm-4.5v', 'glm-4.5-air', 'glm-4.5', 'glm-4-32b', 'glm-4', 'glm4'];
|
||||
models.forEach((key) => {
|
||||
const expectedPrompt = tokenValues[key].prompt;
|
||||
const expectedCompletion = tokenValues[key].completion;
|
||||
expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt);
|
||||
expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion);
|
||||
expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt);
|
||||
expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS Bedrock Model Tests', () => {
|
||||
@@ -494,249 +449,6 @@ describe('AWS Bedrock Model Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Amazon Model Tests', () => {
|
||||
describe('Amazon Nova Models', () => {
|
||||
it('should return correct pricing for nova-premier', () => {
|
||||
expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-premier'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-premier'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-premier'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-premier'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for nova-pro', () => {
|
||||
expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-pro'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-pro'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-pro'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-pro'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for nova-lite', () => {
|
||||
expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-lite'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-lite'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-lite'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-lite'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for nova-micro', () => {
|
||||
expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-micro'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-micro'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['nova-micro'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe(
|
||||
tokenValues['nova-micro'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should match both short and full model names to the same pricing', () => {
|
||||
const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier'];
|
||||
const fullModels = [
|
||||
'amazon.nova-micro-v1:0',
|
||||
'amazon.nova-lite-v1:0',
|
||||
'amazon.nova-pro-v1:0',
|
||||
'amazon.nova-premier-v1:0',
|
||||
];
|
||||
|
||||
models.forEach((shortModel, i) => {
|
||||
const fullModel = fullModels[i];
|
||||
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
|
||||
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
|
||||
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
|
||||
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
|
||||
|
||||
expect(shortPrompt).toBe(fullPrompt);
|
||||
expect(shortCompletion).toBe(fullCompletion);
|
||||
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
|
||||
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Amazon Titan Models', () => {
|
||||
it('should return correct pricing for titan-text-premier', () => {
|
||||
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-premier'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe(
|
||||
tokenValues['titan-text-premier'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-premier'].prompt,
|
||||
);
|
||||
expect(
|
||||
getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }),
|
||||
).toBe(tokenValues['titan-text-premier'].completion);
|
||||
});
|
||||
|
||||
it('should return correct pricing for titan-text-express', () => {
|
||||
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-express'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe(
|
||||
tokenValues['titan-text-express'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-express'].prompt,
|
||||
);
|
||||
expect(
|
||||
getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }),
|
||||
).toBe(tokenValues['titan-text-express'].completion);
|
||||
});
|
||||
|
||||
it('should return correct pricing for titan-text-lite', () => {
|
||||
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-lite'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe(
|
||||
tokenValues['titan-text-lite'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['titan-text-lite'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe(
|
||||
tokenValues['titan-text-lite'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should match both short and full model names to the same pricing', () => {
|
||||
const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier'];
|
||||
const fullModels = [
|
||||
'amazon.titan-text-lite-v1',
|
||||
'amazon.titan-text-express-v1',
|
||||
'amazon.titan-text-premier-v1:0',
|
||||
];
|
||||
|
||||
models.forEach((shortModel, i) => {
|
||||
const fullModel = fullModels[i];
|
||||
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
|
||||
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
|
||||
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
|
||||
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
|
||||
|
||||
expect(shortPrompt).toBe(fullPrompt);
|
||||
expect(shortCompletion).toBe(fullCompletion);
|
||||
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
|
||||
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI21 Model Tests', () => {
|
||||
describe('AI21 J2 Models', () => {
|
||||
it('should return correct pricing for j2-mid', () => {
|
||||
expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['j2-mid'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe(
|
||||
tokenValues['j2-mid'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['j2-mid'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe(
|
||||
tokenValues['j2-mid'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for j2-ultra', () => {
|
||||
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['j2-ultra'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe(
|
||||
tokenValues['j2-ultra'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['j2-ultra'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe(
|
||||
tokenValues['j2-ultra'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should match both short and full model names to the same pricing', () => {
|
||||
const models = ['j2-mid', 'j2-ultra'];
|
||||
const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1'];
|
||||
|
||||
models.forEach((shortModel, i) => {
|
||||
const fullModel = fullModels[i];
|
||||
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
|
||||
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
|
||||
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
|
||||
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
|
||||
|
||||
expect(shortPrompt).toBe(fullPrompt);
|
||||
expect(shortCompletion).toBe(fullCompletion);
|
||||
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
|
||||
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI21 Jamba Models', () => {
|
||||
it('should return correct pricing for jamba-instruct', () => {
|
||||
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['jamba-instruct'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe(
|
||||
tokenValues['jamba-instruct'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['jamba-instruct'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe(
|
||||
tokenValues['jamba-instruct'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should match both short and full model names to the same pricing', () => {
|
||||
const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' });
|
||||
const fullPrompt = getMultiplier({
|
||||
model: 'ai21.jamba-instruct-v1:0',
|
||||
tokenType: 'prompt',
|
||||
});
|
||||
const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' });
|
||||
const fullCompletion = getMultiplier({
|
||||
model: 'ai21.jamba-instruct-v1:0',
|
||||
tokenType: 'completion',
|
||||
});
|
||||
|
||||
expect(shortPrompt).toBe(fullPrompt);
|
||||
expect(shortCompletion).toBe(fullCompletion);
|
||||
expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt);
|
||||
expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deepseek Model Tests', () => {
|
||||
const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1'];
|
||||
|
||||
@@ -768,187 +480,6 @@ describe('Deepseek Model Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen3 Model Tests', () => {
|
||||
describe('Qwen3 Base Models', () => {
|
||||
it('should return correct pricing for qwen3 base pattern', () => {
|
||||
expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-8b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-8b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-8b'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-14b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-14b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-14b'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-235b-a22b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-235b-a22b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-235b-a22b'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle model name variations with provider prefixes', () => {
|
||||
const models = [
|
||||
{ input: 'qwen3', expected: 'qwen3' },
|
||||
{ input: 'qwen3-4b', expected: 'qwen3' },
|
||||
{ input: 'qwen3-8b', expected: 'qwen3-8b' },
|
||||
{ input: 'qwen3-32b', expected: 'qwen3-32b' },
|
||||
];
|
||||
models.forEach(({ input, expected }) => {
|
||||
const withPrefix = `alibaba/${input}`;
|
||||
expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe(
|
||||
tokenValues[expected].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe(
|
||||
tokenValues[expected].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen3 VL (Vision-Language) Models', () => {
|
||||
it('should return correct pricing for qwen3-vl-8b-thinking', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-vl-8b-thinking'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-vl-8b-thinking'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-vl-8b-instruct', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-vl-8b-instruct'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-vl-8b-instruct'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-vl-30b-a3b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-vl-30b-a3b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-vl-30b-a3b'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-vl-235b-a22b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-vl-235b-a22b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-vl-235b-a22b'].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen3 Specialized Models', () => {
|
||||
it('should return correct pricing for qwen3-max', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-max'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-max'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-coder', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-coder'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-coder'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-coder-plus', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-coder-plus'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-coder-plus'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-coder-flash', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-coder-flash'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-coder-flash'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct pricing for qwen3-next-80b-a3b', () => {
|
||||
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['qwen3-next-80b-a3b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3-next-80b-a3b'].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen3 Model Variations', () => {
|
||||
it('should handle all qwen3 models with provider prefixes', () => {
|
||||
const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct'];
|
||||
const prefixes = ['alibaba', 'qwen', 'openrouter'];
|
||||
|
||||
models.forEach((model) => {
|
||||
prefixes.forEach((prefix) => {
|
||||
const fullModel = `${prefix}/${model}`;
|
||||
expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe(
|
||||
tokenValues[model].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe(
|
||||
tokenValues[model].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle qwen3-4b falling back to qwen3 base pattern', () => {
|
||||
const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview'];
|
||||
testCases.forEach((model) => {
|
||||
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt);
|
||||
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
|
||||
tokenValues['qwen3'].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCacheMultiplier', () => {
|
||||
it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
|
||||
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(
|
||||
@@ -1241,110 +772,6 @@ describe('Grok Model Tests - Pricing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GLM Model Tests', () => {
|
||||
it('should return expected value keys for GLM models', () => {
|
||||
expect(getValueKey('glm-4.6')).toBe('glm-4.6');
|
||||
expect(getValueKey('glm-4.5')).toBe('glm-4.5');
|
||||
expect(getValueKey('glm-4.5v')).toBe('glm-4.5v');
|
||||
expect(getValueKey('glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(getValueKey('glm-4-32b')).toBe('glm-4-32b');
|
||||
expect(getValueKey('glm-4')).toBe('glm-4');
|
||||
expect(getValueKey('glm4')).toBe('glm4');
|
||||
});
|
||||
|
||||
it('should match GLM model variations with provider prefixes', () => {
|
||||
expect(getValueKey('z-ai/glm-4.6')).toBe('glm-4.6');
|
||||
expect(getValueKey('z-ai/glm-4.5')).toBe('glm-4.5');
|
||||
expect(getValueKey('z-ai/glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(getValueKey('z-ai/glm-4.5v')).toBe('glm-4.5v');
|
||||
expect(getValueKey('z-ai/glm-4-32b')).toBe('glm-4-32b');
|
||||
|
||||
expect(getValueKey('zai/glm-4.6')).toBe('glm-4.6');
|
||||
expect(getValueKey('zai/glm-4.5')).toBe('glm-4.5');
|
||||
expect(getValueKey('zai/glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(getValueKey('zai/glm-4.5v')).toBe('glm-4.5v');
|
||||
|
||||
expect(getValueKey('zai-org/GLM-4.6')).toBe('glm-4.6');
|
||||
expect(getValueKey('zai-org/GLM-4.5')).toBe('glm-4.5');
|
||||
expect(getValueKey('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air');
|
||||
expect(getValueKey('zai-org/GLM-4.5V')).toBe('glm-4.5v');
|
||||
expect(getValueKey('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b');
|
||||
});
|
||||
|
||||
it('should match GLM model variations with suffixes', () => {
|
||||
expect(getValueKey('glm-4.6-fp8')).toBe('glm-4.6');
|
||||
expect(getValueKey('zai-org/GLM-4.6-FP8')).toBe('glm-4.6');
|
||||
expect(getValueKey('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air');
|
||||
});
|
||||
|
||||
it('should prioritize more specific GLM model patterns', () => {
|
||||
expect(getValueKey('glm-4.5-air-something')).toBe('glm-4.5-air');
|
||||
expect(getValueKey('glm-4.5-something')).toBe('glm-4.5');
|
||||
expect(getValueKey('glm-4.5v-something')).toBe('glm-4.5v');
|
||||
});
|
||||
|
||||
it('should return correct multipliers for all GLM models', () => {
|
||||
expect(getMultiplier({ model: 'glm-4.6', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.6'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4.6', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4.6'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.5v'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4.5v'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.5-air'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4.5-air'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm-4.5', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.5'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4.5', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4.5'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4-32b'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4-32b'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm-4', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'glm-4', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4'].completion,
|
||||
);
|
||||
|
||||
expect(getMultiplier({ model: 'glm4', tokenType: 'prompt' })).toBe(tokenValues['glm4'].prompt);
|
||||
expect(getMultiplier({ model: 'glm4', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm4'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct multipliers for GLM models with provider prefixes', () => {
|
||||
expect(getMultiplier({ model: 'z-ai/glm-4.6', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.6'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'zai/glm-4.5-air', tokenType: 'completion' })).toBe(
|
||||
tokenValues['glm-4.5-air'].completion,
|
||||
);
|
||||
expect(getMultiplier({ model: 'zai-org/GLM-4.5V', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['glm-4.5v'].prompt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude Model Tests', () => {
|
||||
it('should return correct prompt and completion rates for Claude 4 models', () => {
|
||||
expect(getMultiplier({ model: 'claude-sonnet-4', tokenType: 'prompt' })).toBe(
|
||||
@@ -1361,37 +788,6 @@ describe('Claude Model Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct prompt and completion rates for Claude Haiku 4.5', () => {
|
||||
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['claude-haiku-4-5'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe(
|
||||
tokenValues['claude-haiku-4-5'].completion,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Claude Haiku 4.5 model name variations', () => {
|
||||
const modelVariations = [
|
||||
'claude-haiku-4-5',
|
||||
'claude-haiku-4-5-20250420',
|
||||
'claude-haiku-4-5-latest',
|
||||
'anthropic/claude-haiku-4-5',
|
||||
'claude-haiku-4-5/anthropic',
|
||||
'claude-haiku-4-5-preview',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const valueKey = getValueKey(model);
|
||||
expect(valueKey).toBe('claude-haiku-4-5');
|
||||
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
|
||||
tokenValues['claude-haiku-4-5'].prompt,
|
||||
);
|
||||
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
|
||||
tokenValues['claude-haiku-4-5'].completion,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4',
|
||||
@@ -1469,119 +865,3 @@ describe('Claude Model Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokens.ts and tx.js sync validation', () => {
|
||||
it('should resolve all models in maxTokensMap to pricing via getValueKey', () => {
|
||||
const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]);
|
||||
const txKeys = Object.keys(tokenValues);
|
||||
|
||||
const unresolved = [];
|
||||
|
||||
tokensKeys.forEach((key) => {
|
||||
// Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k')
|
||||
if (/^\d+k$/.test(key)) return;
|
||||
|
||||
// Skip generic pattern keys (end with '-' or ':')
|
||||
if (key.endsWith('-') || key.endsWith(':')) return;
|
||||
|
||||
// Try to resolve via getValueKey
|
||||
const resolvedKey = getValueKey(key);
|
||||
|
||||
// If it resolves and the resolved key has pricing, success
|
||||
if (resolvedKey && txKeys.includes(resolvedKey)) return;
|
||||
|
||||
// If it resolves to a legacy key (4k, 8k, etc), also OK
|
||||
if (resolvedKey && /^\d+k$/.test(resolvedKey)) return;
|
||||
|
||||
// If we get here, this model can't get pricing - flag it
|
||||
unresolved.push({
|
||||
key,
|
||||
resolvedKey: resolvedKey || 'undefined',
|
||||
context: maxTokensMap[EModelEndpoint.openAI][key],
|
||||
});
|
||||
});
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
console.log('\nModels that cannot resolve to pricing via getValueKey:');
|
||||
unresolved.forEach(({ key, resolvedKey, context }) => {
|
||||
console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not have redundant dated variants with same pricing and context as base model', () => {
|
||||
const txKeys = Object.keys(tokenValues);
|
||||
const redundant = [];
|
||||
|
||||
txKeys.forEach((key) => {
|
||||
// Check if this is a dated variant (ends with -YYYY-MM-DD)
|
||||
if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) {
|
||||
const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, '');
|
||||
|
||||
if (txKeys.includes(baseKey)) {
|
||||
const variantPricing = tokenValues[key];
|
||||
const basePricing = tokenValues[baseKey];
|
||||
const variantContext = maxTokensMap[EModelEndpoint.openAI][key];
|
||||
const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey];
|
||||
|
||||
const samePricing =
|
||||
variantPricing.prompt === basePricing.prompt &&
|
||||
variantPricing.completion === basePricing.completion;
|
||||
const sameContext = variantContext === baseContext;
|
||||
|
||||
if (samePricing && sameContext) {
|
||||
redundant.push({
|
||||
key,
|
||||
baseKey,
|
||||
pricing: `${variantPricing.prompt}/${variantPricing.completion}`,
|
||||
context: variantContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (redundant.length > 0) {
|
||||
console.log('\nRedundant dated variants found (same pricing and context as base):');
|
||||
redundant.forEach(({ key, baseKey, pricing, context }) => {
|
||||
console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`);
|
||||
console.log(` Can be removed - pattern matching will handle it`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(redundant).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => {
|
||||
const txKeys = Object.keys(tokenValues);
|
||||
const missingContext = [];
|
||||
|
||||
txKeys.forEach((key) => {
|
||||
// Skip legacy token size mappings (4k, 8k, 16k, 32k)
|
||||
if (/^\d+k$/.test(key)) return;
|
||||
|
||||
// Check if this model has a context window defined
|
||||
const context = maxTokensMap[EModelEndpoint.openAI][key];
|
||||
|
||||
if (!context) {
|
||||
const pricing = tokenValues[key];
|
||||
missingContext.push({
|
||||
key,
|
||||
pricing: `${pricing.prompt}/${pricing.completion}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (missingContext.length > 0) {
|
||||
console.log('\nModels with pricing but missing context in tokens.ts:');
|
||||
missingContext.forEach(({ key, pricing }) => {
|
||||
console.log(` - '${key}' (pricing: ${pricing})`);
|
||||
console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(missingContext).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -47,8 +47,9 @@
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.90",
|
||||
"@librechat/agents": "^2.4.82",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
@@ -93,7 +94,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^3.3.7",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^5.10.1",
|
||||
"openid-client": "^6.5.0",
|
||||
|
||||
@@ -116,15 +116,11 @@ const refreshController = async (req, res) => {
|
||||
const token = await setAuthTokens(userId, res, session);
|
||||
|
||||
// trigger OAuth MCP server reconnection asynchronously (best effort)
|
||||
try {
|
||||
void getOAuthReconnectionManager()
|
||||
.reconnectServers(userId)
|
||||
.catch((err) => {
|
||||
logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
|
||||
}
|
||||
void getOAuthReconnectionManager()
|
||||
.reconnectServers(userId)
|
||||
.catch((err) => {
|
||||
logger.error('Error reconnecting OAuth MCP servers:', err);
|
||||
});
|
||||
|
||||
res.status(200).send({ token, user });
|
||||
} else if (req?.query?.retry) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const { logger, webSearchKeys } = require('@librechat/data-schemas');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider');
|
||||
const {
|
||||
webSearchKeys,
|
||||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
normalizeHttpError,
|
||||
@@ -327,23 +328,16 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
const revocationEndpointAuthMethodsSupported =
|
||||
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
|
||||
clientMetadata.revocation_endpoint_auth_methods_supported;
|
||||
const oauthHeaders = serverConfig.oauth_headers ?? {};
|
||||
|
||||
if (tokens?.access_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(
|
||||
serverName,
|
||||
tokens.access_token,
|
||||
'access',
|
||||
{
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
},
|
||||
oauthHeaders,
|
||||
);
|
||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
|
||||
}
|
||||
@@ -351,19 +345,13 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
|
||||
if (tokens?.refresh_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(
|
||||
serverName,
|
||||
tokens.refresh_token,
|
||||
'refresh',
|
||||
{
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
},
|
||||
oauthHeaders,
|
||||
);
|
||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
logAxiosError,
|
||||
sanitizeTitle,
|
||||
resolveHeaders,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
@@ -212,13 +211,16 @@ class AgentClient extends BaseClient {
|
||||
* @returns {Promise<Array<Partial<MongoFile>>>}
|
||||
*/
|
||||
async addImageURLs(message, attachments) {
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
const { files, text, image_urls } = await encodeAndFormat(
|
||||
this.options.req,
|
||||
attachments,
|
||||
this.options.agent.provider,
|
||||
VisionModes.agents,
|
||||
);
|
||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||
if (text && text.length) {
|
||||
message.ocr = text;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -246,18 +248,19 @@ class AgentClient extends BaseClient {
|
||||
|
||||
if (this.options.attachments) {
|
||||
const attachments = await this.options.attachments;
|
||||
const latestMessage = orderedMessages[orderedMessages.length - 1];
|
||||
|
||||
if (this.message_file_map) {
|
||||
this.message_file_map[latestMessage.messageId] = attachments;
|
||||
this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments;
|
||||
} else {
|
||||
this.message_file_map = {
|
||||
[latestMessage.messageId]: attachments,
|
||||
[orderedMessages[orderedMessages.length - 1].messageId]: attachments,
|
||||
};
|
||||
}
|
||||
|
||||
await this.addFileContextToMessage(latestMessage, attachments);
|
||||
const files = await this.processAttachments(latestMessage, attachments);
|
||||
const files = await this.addImageURLs(
|
||||
orderedMessages[orderedMessages.length - 1],
|
||||
attachments,
|
||||
);
|
||||
|
||||
this.options.attachments = files;
|
||||
}
|
||||
@@ -277,21 +280,21 @@ class AgentClient extends BaseClient {
|
||||
assistantName: this.options?.modelLabel,
|
||||
});
|
||||
|
||||
if (message.fileContext && i !== orderedMessages.length - 1) {
|
||||
if (message.ocr && i !== orderedMessages.length - 1) {
|
||||
if (typeof formattedMessage.content === 'string') {
|
||||
formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
|
||||
formattedMessage.content = message.ocr + '\n' + formattedMessage.content;
|
||||
} else {
|
||||
const textPart = formattedMessage.content.find((part) => part.type === 'text');
|
||||
textPart
|
||||
? (textPart.text = message.fileContext + '\n' + textPart.text)
|
||||
: formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
|
||||
? (textPart.text = message.ocr + '\n' + textPart.text)
|
||||
: formattedMessage.content.unshift({ type: 'text', text: message.ocr });
|
||||
}
|
||||
} else if (message.fileContext && i === orderedMessages.length - 1) {
|
||||
systemContent = [systemContent, message.fileContext].join('\n');
|
||||
} else if (message.ocr && i === orderedMessages.length - 1) {
|
||||
systemContent = [systemContent, message.ocr].join('\n');
|
||||
}
|
||||
|
||||
const needsTokenCount =
|
||||
(this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext;
|
||||
(this.contextStrategy && !orderedMessages[i].tokenCount) || message.ocr;
|
||||
|
||||
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
||||
if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
|
||||
@@ -776,7 +779,6 @@ class AgentClient extends BaseClient {
|
||||
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
|
||||
|
||||
config = {
|
||||
runName: 'AgentRun',
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
last_agent_index: this.agentConfigs?.size ?? 0,
|
||||
@@ -1114,8 +1116,8 @@ class AgentClient extends BaseClient {
|
||||
appConfig.endpoints?.[endpoint] ??
|
||||
titleProviderConfig.customEndpointConfig;
|
||||
if (!endpointConfig) {
|
||||
logger.debug(
|
||||
`[api/server/controllers/agents/client.js #titleConvo] No endpoint config for "${endpoint}"`,
|
||||
logger.warn(
|
||||
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1235,10 +1237,6 @@ class AgentClient extends BaseClient {
|
||||
handleLLMEnd,
|
||||
},
|
||||
],
|
||||
configurable: {
|
||||
thread_id: this.conversationId,
|
||||
user_id: this.user ?? this.options.req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1276,7 +1274,7 @@ class AgentClient extends BaseClient {
|
||||
);
|
||||
});
|
||||
|
||||
return sanitizeTitle(titleResult.title);
|
||||
return titleResult.title;
|
||||
} catch (err) {
|
||||
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
|
||||
return;
|
||||
|
||||
@@ -10,10 +10,6 @@ jest.mock('@librechat/agents', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
@@ -256,38 +252,6 @@ describe('AgentClient - titleConvo', () => {
|
||||
expect(result).toBe('Generated Title');
|
||||
});
|
||||
|
||||
it('should sanitize the generated title by removing think blocks', async () => {
|
||||
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleWithThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should remove the <think> block and return only the clean title
|
||||
expect(result).toBe('User Hi Greeting');
|
||||
expect(result).not.toContain('<think>');
|
||||
expect(result).not.toContain('</think>');
|
||||
});
|
||||
|
||||
it('should return fallback title when sanitization results in empty string', async () => {
|
||||
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
|
||||
mockRun.generateTitle.mockResolvedValue({
|
||||
title: titleOnlyThinkBlock,
|
||||
});
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
// Should return the fallback title since sanitization would result in empty string
|
||||
expect(result).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return undefined', async () => {
|
||||
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const getMCPTools = async (req, res) => {
|
||||
const mcpServers = {};
|
||||
|
||||
const cachePromises = configuredServers.map((serverName) =>
|
||||
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
|
||||
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
|
||||
);
|
||||
const cacheResults = await Promise.all(cachePromises);
|
||||
|
||||
@@ -52,7 +52,7 @@ const getMCPTools = async (req, res) => {
|
||||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
|
||||
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,7 @@ const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const {
|
||||
isEnabled,
|
||||
ErrorController,
|
||||
performStartupChecks,
|
||||
initializeFileStorage,
|
||||
} = require('@librechat/api');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
||||
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
||||
@@ -54,11 +49,9 @@ const startServer = async () => {
|
||||
app.set('trust proxy', trusted_proxy);
|
||||
|
||||
await seedDatabase();
|
||||
const appConfig = await getAppConfig();
|
||||
initializeFileStorage(appConfig);
|
||||
await performStartupChecks(appConfig);
|
||||
await updateInterfacePermissions(appConfig);
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
await updateInterfacePermissions(appConfig);
|
||||
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
||||
let indexHTML = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const { Keyv } = require('keyv');
|
||||
const uap = require('ua-parser-js');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, keyvMongo } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const keyvMongo = require('~/cache/keyvMongo');
|
||||
const denyRequest = require('./denyRequest');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const denyRequest = require('~/server/middleware/denyRequest');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const logViolation = require('~/cache/logViolation');
|
||||
|
||||
const getEnvironmentVariables = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { removePorts } = require('~/server/utils');
|
||||
const { limiterCache } = require('~/cache/cacheFactory');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const {
|
||||
|
||||
@@ -47,7 +47,6 @@ jest.mock('~/models', () => ({
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
setCachedTools: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
loadCustomConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -128,13 +127,8 @@ describe('MCP Routes', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
||||
authorizationUrl: 'https://oauth.example.com/auth',
|
||||
@@ -152,7 +146,6 @@ describe('MCP Routes', () => {
|
||||
'test-server',
|
||||
'https://test-server.com',
|
||||
'test-user-id',
|
||||
{},
|
||||
{ clientId: 'test-client-id' },
|
||||
);
|
||||
});
|
||||
@@ -321,7 +314,6 @@ describe('MCP Routes', () => {
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -344,7 +336,6 @@ describe('MCP Routes', () => {
|
||||
'test-flow-id',
|
||||
'test-auth-code',
|
||||
mockFlowManager,
|
||||
{},
|
||||
);
|
||||
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -401,11 +392,6 @@ describe('MCP Routes', () => {
|
||||
getLogStores.mockReturnValue({});
|
||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||
code: 'test-auth-code',
|
||||
state: 'test-flow-id',
|
||||
@@ -441,7 +427,6 @@ describe('MCP Routes', () => {
|
||||
|
||||
const mockMcpManager = {
|
||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -1249,7 +1234,6 @@ describe('MCP Routes', () => {
|
||||
getUserConnection: jest.fn().mockResolvedValue({
|
||||
fetchTools: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
@@ -1297,7 +1281,6 @@ describe('MCP Routes', () => {
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
||||
}),
|
||||
getRawConfig: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||
|
||||
|
||||
@@ -115,9 +115,6 @@ router.get('/', async function (req, res) {
|
||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||
openidReuseTokens,
|
||||
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
};
|
||||
|
||||
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
||||
@@ -159,7 +156,7 @@ router.get('/', async function (req, res) {
|
||||
if (
|
||||
webSearchConfig != null &&
|
||||
(webSearchConfig.searchProvider ||
|
||||
webSearchConfig.scraperProvider ||
|
||||
webSearchConfig.scraperType ||
|
||||
webSearchConfig.rerankerType)
|
||||
) {
|
||||
payload.webSearch = {};
|
||||
@@ -168,8 +165,8 @@ router.get('/', async function (req, res) {
|
||||
if (webSearchConfig?.searchProvider) {
|
||||
payload.webSearch.searchProvider = webSearchConfig.searchProvider;
|
||||
}
|
||||
if (webSearchConfig?.scraperProvider) {
|
||||
payload.webSearch.scraperProvider = webSearchConfig.scraperProvider;
|
||||
if (webSearchConfig?.scraperType) {
|
||||
payload.webSearch.scraperType = webSearchConfig.scraperType;
|
||||
}
|
||||
if (webSearchConfig?.rerankerType) {
|
||||
payload.webSearch.rerankerType = webSearchConfig.rerankerType;
|
||||
|
||||
@@ -65,7 +65,6 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
|
||||
serverName,
|
||||
serverUrl,
|
||||
userId,
|
||||
getOAuthHeaders(serverName),
|
||||
oauthConfig,
|
||||
);
|
||||
|
||||
@@ -133,12 +132,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
});
|
||||
|
||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(
|
||||
flowId,
|
||||
code,
|
||||
flowManager,
|
||||
getOAuthHeaders(serverName),
|
||||
);
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||
|
||||
/** Persist tokens immediately so reconnection uses fresh credentials */
|
||||
@@ -205,7 +199,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPServerTools({
|
||||
userId: flowState.userId,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
@@ -545,10 +538,4 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function getOAuthHeaders(serverName) {
|
||||
const mcpManager = getMCPManager();
|
||||
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||
return serverConfig?.oauth_headers ?? {};
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -99,8 +99,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
|
||||
|
||||
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const { targetMessageId } = req.body;
|
||||
const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId);
|
||||
const created = await createSharedLink(req.user.id, req.params.conversationId);
|
||||
if (created) {
|
||||
res.status(200).json(created);
|
||||
} else {
|
||||
|
||||
198
api/server/services/AppService.interface.spec.js
Normal file
198
api/server/services/AppService.interface.spec.js
Normal file
@@ -0,0 +1,198 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
loadDefaultInterface: jest.fn(),
|
||||
}));
|
||||
jest.mock('./start/tools', () => ({
|
||||
loadAndFormatTools: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('./start/checks', () => ({
|
||||
checkVariables: jest.fn(),
|
||||
checkHealth: jest.fn(),
|
||||
checkConfig: jest.fn(),
|
||||
checkAzureVariables: jest.fn(),
|
||||
checkWebSearchConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./Config/loadCustomConfig', () => jest.fn());
|
||||
|
||||
const AppService = require('./AppService');
|
||||
const { loadDefaultInterface } = require('@librechat/api');
|
||||
|
||||
describe('AppService interface configuration', () => {
|
||||
let mockLoadCustomConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
mockLoadCustomConfig = require('./Config/loadCustomConfig');
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true });
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } });
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false });
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
prompts: false,
|
||||
bookmarks: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({});
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.anything(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that prompts and bookmarks are undefined when not provided
|
||||
expect(result.interfaceConfig.prompts).toBeUndefined();
|
||||
expect(result.interfaceConfig.bookmarks).toBeUndefined();
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } });
|
||||
loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false });
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
prompts: true,
|
||||
bookmarks: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly configure peoplePicker permissions including roles', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
peoplePicker: expect.objectContaining({
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(loadDefaultInterface).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed peoplePicker permissions', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
peoplePicker: expect.objectContaining({
|
||||
users: true,
|
||||
groups: false,
|
||||
roles: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set default peoplePicker permissions when not provided', async () => {
|
||||
mockLoadCustomConfig.mockResolvedValue({});
|
||||
loadDefaultInterface.mockResolvedValue({
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
interfaceConfig: expect.objectContaining({
|
||||
peoplePicker: expect.objectContaining({
|
||||
users: true,
|
||||
groups: true,
|
||||
roles: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,48 @@
|
||||
import { EModelEndpoint, getConfigDefaults } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, FileSources, DeepPartial } from 'librechat-data-provider';
|
||||
import type { AppConfig, FunctionTool } from '~/types/app';
|
||||
import { loadDefaultInterface } from './interface';
|
||||
import { loadTurnstileConfig } from './turnstile';
|
||||
import { agentsConfigSetup } from './agents';
|
||||
import { loadWebSearchConfig } from './web';
|
||||
import { processModelSpecs } from './specs';
|
||||
import { loadMemoryConfig } from './memory';
|
||||
import { loadEndpoints } from './endpoints';
|
||||
import { loadOCRConfig } from './ocr';
|
||||
|
||||
export type Paths = {
|
||||
root: string;
|
||||
uploads: string;
|
||||
clientPath: string;
|
||||
dist: string;
|
||||
publicPath: string;
|
||||
fonts: string;
|
||||
assets: string;
|
||||
imageOutput: string;
|
||||
structuredTools: string;
|
||||
pluginManifest: string;
|
||||
};
|
||||
const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider');
|
||||
const {
|
||||
isEnabled,
|
||||
loadOCRConfig,
|
||||
loadMemoryConfig,
|
||||
agentsConfigSetup,
|
||||
loadWebSearchConfig,
|
||||
loadDefaultInterface,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
checkWebSearchConfig,
|
||||
checkVariables,
|
||||
checkHealth,
|
||||
checkConfig,
|
||||
} = require('./start/checks');
|
||||
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const { loadTurnstileConfig } = require('./start/turnstile');
|
||||
const { processModelSpecs } = require('./start/modelSpecs');
|
||||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
const { loadAndFormatTools } = require('./start/tools');
|
||||
const { loadEndpoints } = require('./start/endpoints');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
/**
|
||||
* Loads custom config and initializes app-wide variables.
|
||||
* @function AppService
|
||||
*/
|
||||
export const AppService = async (params?: {
|
||||
config: DeepPartial<TCustomConfig>;
|
||||
paths?: Paths;
|
||||
systemTools?: Record<string, FunctionTool>;
|
||||
}): Promise<AppConfig> => {
|
||||
const { config, paths, systemTools } = params || {};
|
||||
if (!config) {
|
||||
throw new Error('Config is required');
|
||||
}
|
||||
const AppService = async () => {
|
||||
/** @type {TCustomConfig} */
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
const configDefaults = getConfigDefaults();
|
||||
|
||||
const ocr = loadOCRConfig(config.ocr);
|
||||
const webSearch = loadWebSearchConfig(config.webSearch);
|
||||
checkWebSearchConfig(webSearch);
|
||||
const memory = loadMemoryConfig(config.memory);
|
||||
const filteredTools = config.filteredTools;
|
||||
const includedTools = config.includedTools;
|
||||
const fileStrategy = (config.fileStrategy ?? configDefaults.fileStrategy) as
|
||||
| FileSources.local
|
||||
| FileSources.s3
|
||||
| FileSources.firebase
|
||||
| FileSources.azure_blob;
|
||||
const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy;
|
||||
const startBalance = process.env.START_BALANCE;
|
||||
const balance = config.balance ?? {
|
||||
enabled: process.env.CHECK_BALANCE?.toLowerCase().trim() === 'true',
|
||||
enabled: isEnabled(process.env.CHECK_BALANCE),
|
||||
startBalance: startBalance ? parseInt(startBalance, 10) : undefined,
|
||||
};
|
||||
const transactions = config.transactions ?? configDefaults.transactions;
|
||||
@@ -58,7 +50,23 @@ export const AppService = async (params?: {
|
||||
|
||||
process.env.CDN_PROVIDER = fileStrategy;
|
||||
|
||||
const availableTools = systemTools;
|
||||
checkVariables();
|
||||
await checkHealth();
|
||||
|
||||
if (fileStrategy === FileSources.firebase) {
|
||||
initializeFirebase();
|
||||
} else if (fileStrategy === FileSources.azure_blob) {
|
||||
initializeAzureBlobService();
|
||||
} else if (fileStrategy === FileSources.s3) {
|
||||
initializeS3();
|
||||
}
|
||||
|
||||
/** @type {Record<string, FunctionTool>} */
|
||||
const availableTools = loadAndFormatTools({
|
||||
adminFilter: filteredTools,
|
||||
adminIncluded: includedTools,
|
||||
directory: paths.structuredTools,
|
||||
});
|
||||
|
||||
const mcpConfig = config.mcpServers || null;
|
||||
const registration = config.registration ?? configDefaults.registration;
|
||||
@@ -99,6 +107,8 @@ export const AppService = async (params?: {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
checkConfig(config);
|
||||
handleRateLimits(config?.rateLimits);
|
||||
const loadedEndpoints = loadEndpoints(config, agentsDefaults);
|
||||
|
||||
const appConfig = {
|
||||
@@ -111,3 +121,5 @@ export const AppService = async (params?: {
|
||||
|
||||
return appConfig;
|
||||
};
|
||||
|
||||
module.exports = AppService;
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
OCRStrategy,
|
||||
const {
|
||||
FileSources,
|
||||
EModelEndpoint,
|
||||
EImageOutputType,
|
||||
@@ -7,8 +6,9 @@ import {
|
||||
defaultSocialLogins,
|
||||
validateAzureGroups,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
deprecatedAzureVariables,
|
||||
conflictingAzureVariables,
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
@@ -20,7 +20,48 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppService } from '@librechat/data-schemas';
|
||||
const AppService = require('./AppService');
|
||||
|
||||
jest.mock('./Files/Firebase/initialize', () => ({
|
||||
initializeFirebase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./Config/loadCustomConfig', () =>
|
||||
jest.fn(() =>
|
||||
Promise.resolve({
|
||||
registration: { socialLogins: ['testLogin'] },
|
||||
fileStrategy: 'testStrategy',
|
||||
balance: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
jest.mock('./start/tools', () => ({
|
||||
loadAndFormatTools: jest.fn().mockReturnValue({
|
||||
ExampleTool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
description: 'Example tool function',
|
||||
name: 'exampleFunction',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: { type: 'string', description: 'An example parameter' },
|
||||
},
|
||||
required: ['param1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('./start/turnstile', () => ({
|
||||
loadTurnstileConfig: jest.fn(() => ({
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const azureGroups = [
|
||||
{
|
||||
@@ -56,26 +97,20 @@ const azureGroups = [
|
||||
models: {
|
||||
'gpt-4-turbo': true,
|
||||
},
|
||||
} as const,
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('./start/checks', () => ({
|
||||
...jest.requireActual('./start/checks'),
|
||||
checkHealth: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('AppService', () => {
|
||||
const mockSystemTools = {
|
||||
ExampleTool: {
|
||||
type: 'function',
|
||||
function: {
|
||||
description: 'Example tool function',
|
||||
name: 'exampleFunction',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: { type: 'string', description: 'An example parameter' },
|
||||
},
|
||||
required: ['param1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
const mockedTurnstileConfig = {
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
};
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CDN_PROVIDER = undefined;
|
||||
@@ -83,15 +118,7 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly assign process.env and initialize app config based on custom config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
registration: { socialLogins: ['testLogin'] },
|
||||
fileStrategy: 'testStrategy' as FileSources,
|
||||
balance: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await AppService({ config, systemTools: mockSystemTools });
|
||||
const result = await AppService();
|
||||
|
||||
expect(process.env.CDN_PROVIDER).toEqual('testStrategy');
|
||||
|
||||
@@ -112,6 +139,9 @@ describe('AppService', () => {
|
||||
presets: true,
|
||||
}),
|
||||
mcpConfig: null,
|
||||
turnstileConfig: mockedTurnstileConfig,
|
||||
modelSpecs: undefined,
|
||||
paths: expect.anything(),
|
||||
imageOutputType: expect.any(String),
|
||||
fileConfig: undefined,
|
||||
secureImageLinks: undefined,
|
||||
@@ -143,13 +173,30 @@ describe('AppService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should change the `imageOutputType` based on config value', async () => {
|
||||
const config = {
|
||||
version: '0.10.0',
|
||||
imageOutputType: EImageOutputType.WEBP,
|
||||
};
|
||||
it('should log a warning if the config version is outdated', async () => {
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
version: '0.9.0', // An outdated version for this test
|
||||
registration: { socialLogins: ['testLogin'] },
|
||||
fileStrategy: 'testStrategy',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
await AppService();
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version'));
|
||||
});
|
||||
|
||||
it('should change the `imageOutputType` based on config value', async () => {
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
version: '0.10.0',
|
||||
imageOutputType: EImageOutputType.WEBP,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
imageOutputType: EImageOutputType.WEBP,
|
||||
@@ -158,11 +205,13 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should default to `PNG` `imageOutputType` with no provided type', async () => {
|
||||
const config = {
|
||||
version: '0.10.0',
|
||||
};
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
version: '0.10.0',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
imageOutputType: EImageOutputType.PNG,
|
||||
@@ -171,9 +220,9 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should default to `PNG` `imageOutputType` with no provided config', async () => {
|
||||
const config = {};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined));
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
imageOutputType: EImageOutputType.PNG,
|
||||
@@ -181,14 +230,35 @@ describe('AppService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should load and format tools accurately with defined structure', async () => {
|
||||
const config = {};
|
||||
it('should initialize Firebase when fileStrategy is firebase', async () => {
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
fileStrategy: FileSources.firebase,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config, systemTools: mockSystemTools });
|
||||
await AppService();
|
||||
|
||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
expect(initializeFirebase).toHaveBeenCalled();
|
||||
|
||||
expect(process.env.CDN_PROVIDER).toEqual(FileSources.firebase);
|
||||
});
|
||||
|
||||
it('should load and format tools accurately with defined structure', async () => {
|
||||
const { loadAndFormatTools } = require('./start/tools');
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
expect(loadAndFormatTools).toHaveBeenCalledWith({
|
||||
adminFilter: undefined,
|
||||
adminIncluded: undefined,
|
||||
directory: expect.anything(),
|
||||
});
|
||||
|
||||
// Verify tools are included in the returned config
|
||||
expect(result.availableTools).toBeDefined();
|
||||
expect(result.availableTools?.ExampleTool).toEqual({
|
||||
expect(result.availableTools.ExampleTool).toEqual({
|
||||
type: 'function',
|
||||
function: {
|
||||
description: 'Example tool function',
|
||||
@@ -205,19 +275,21 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure Assistants endpoint based on custom config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.assistants]: {
|
||||
disableBuilder: true,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMs: 30000,
|
||||
supportedIds: ['id1', 'id2'],
|
||||
privateAssistants: false,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.assistants]: {
|
||||
disableBuilder: true,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMs: 30000,
|
||||
supportedIds: ['id1', 'id2'],
|
||||
privateAssistants: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -235,19 +307,21 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure Agents endpoint based on custom config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: true,
|
||||
recursionLimit: 10,
|
||||
maxRecursionLimit: 20,
|
||||
allowedProviders: ['openai', 'anthropic'],
|
||||
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: true,
|
||||
recursionLimit: 10,
|
||||
maxRecursionLimit: 20,
|
||||
allowedProviders: ['openai', 'anthropic'],
|
||||
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -268,9 +342,9 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should configure Agents endpoint with defaults when no config is provided', async () => {
|
||||
const config = {};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve({}));
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -285,15 +359,17 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => {
|
||||
const config = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -312,19 +388,21 @@ describe('AppService', () => {
|
||||
|
||||
it('should correctly configure minimum Azure OpenAI Assistant values', async () => {
|
||||
const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }];
|
||||
const config = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: assistantGroups,
|
||||
assistants: true,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: assistantGroups,
|
||||
assistants: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.WESTUS_API_KEY = 'westus-key';
|
||||
process.env.EASTUS_API_KEY = 'eastus-key';
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
endpoints: expect.objectContaining({
|
||||
@@ -341,18 +419,20 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure Azure OpenAI endpoint based on custom config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.WESTUS_API_KEY = 'westus-key';
|
||||
process.env.EASTUS_API_KEY = 'eastus-key';
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups);
|
||||
expect(result).toEqual(
|
||||
@@ -376,9 +456,8 @@ describe('AppService', () => {
|
||||
process.env.FILE_UPLOAD_USER_WINDOW = '20';
|
||||
|
||||
const initialEnv = { ...process.env };
|
||||
const config = {};
|
||||
|
||||
await AppService({ config });
|
||||
await AppService();
|
||||
|
||||
// Expect environment variables to remain unchanged
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX);
|
||||
@@ -387,15 +466,38 @@ describe('AppService', () => {
|
||||
expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW);
|
||||
});
|
||||
|
||||
it('should correctly set FILE_UPLOAD environment variables based on rate limits', async () => {
|
||||
// Define and mock a custom configuration with rate limits
|
||||
const rateLimitsConfig = {
|
||||
rateLimits: {
|
||||
fileUploads: {
|
||||
ipMax: '100',
|
||||
ipWindowInMinutes: '60',
|
||||
userMax: '50',
|
||||
userWindowInMinutes: '30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig));
|
||||
|
||||
await AppService();
|
||||
|
||||
// Verify that process.env has been updated according to the rate limits config
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100');
|
||||
expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60');
|
||||
expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50');
|
||||
expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30');
|
||||
});
|
||||
|
||||
it('should fallback to default FILE_UPLOAD environment variables when rate limits are unspecified', async () => {
|
||||
// Setup initial environment variables to non-default values
|
||||
process.env.FILE_UPLOAD_IP_MAX = 'initialMax';
|
||||
process.env.FILE_UPLOAD_IP_WINDOW = 'initialWindow';
|
||||
process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax';
|
||||
process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow';
|
||||
const config = {};
|
||||
|
||||
await AppService({ config });
|
||||
await AppService();
|
||||
|
||||
// Verify that process.env falls back to the initial values
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax');
|
||||
@@ -412,9 +514,8 @@ describe('AppService', () => {
|
||||
process.env.IMPORT_USER_WINDOW = '20';
|
||||
|
||||
const initialEnv = { ...process.env };
|
||||
const config = {};
|
||||
|
||||
await AppService({ config });
|
||||
await AppService();
|
||||
|
||||
// Expect environment variables to remain unchanged
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX);
|
||||
@@ -423,15 +524,38 @@ describe('AppService', () => {
|
||||
expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW);
|
||||
});
|
||||
|
||||
it('should correctly set IMPORT environment variables based on rate limits', async () => {
|
||||
// Define and mock a custom configuration with rate limits
|
||||
const importLimitsConfig = {
|
||||
rateLimits: {
|
||||
conversationsImport: {
|
||||
ipMax: '150',
|
||||
ipWindowInMinutes: '60',
|
||||
userMax: '50',
|
||||
userWindowInMinutes: '30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig));
|
||||
|
||||
await AppService();
|
||||
|
||||
// Verify that process.env has been updated according to the rate limits config
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual('150');
|
||||
expect(process.env.IMPORT_IP_WINDOW).toEqual('60');
|
||||
expect(process.env.IMPORT_USER_MAX).toEqual('50');
|
||||
expect(process.env.IMPORT_USER_WINDOW).toEqual('30');
|
||||
});
|
||||
|
||||
it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => {
|
||||
// Setup initial environment variables to non-default values
|
||||
process.env.IMPORT_IP_MAX = 'initialMax';
|
||||
process.env.IMPORT_IP_WINDOW = 'initialWindow';
|
||||
process.env.IMPORT_USER_MAX = 'initialUserMax';
|
||||
process.env.IMPORT_USER_WINDOW = 'initialUserWindow';
|
||||
const config = {};
|
||||
|
||||
await AppService({ config });
|
||||
await AppService();
|
||||
|
||||
// Verify that process.env falls back to the initial values
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual('initialMax');
|
||||
@@ -441,32 +565,34 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Custom title prompt for conversation',
|
||||
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Custom title prompt for conversation',
|
||||
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||
},
|
||||
[EModelEndpoint.assistants]: {
|
||||
titleMethod: 'functions',
|
||||
titlePrompt: 'Generate a title for this assistant conversation',
|
||||
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
titleConvo: true,
|
||||
titleMethod: 'completion',
|
||||
titleModel: 'gpt-4',
|
||||
titlePrompt: 'Azure title prompt',
|
||||
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||
},
|
||||
},
|
||||
[EModelEndpoint.assistants]: {
|
||||
titleMethod: 'functions',
|
||||
titlePrompt: 'Generate a title for this assistant conversation',
|
||||
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
titleConvo: true,
|
||||
titleMethod: 'completion',
|
||||
titleModel: 'gpt-4',
|
||||
titlePrompt: 'Azure title prompt',
|
||||
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -499,25 +625,24 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should configure Agent endpoint with title generation settings', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: false,
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||
recursionLimit: 15,
|
||||
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||
maxCitations: 30,
|
||||
maxCitationsPerFile: 7,
|
||||
minRelevanceScore: 0.45,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: false,
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||
recursionLimit: 15,
|
||||
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -541,16 +666,18 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should handle missing title configuration options with defaults', async () => {
|
||||
const config = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
// titlePrompt and titlePromptTemplate are not provided
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
// titlePrompt and titlePromptTemplate are not provided
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -569,27 +696,24 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure titleEndpoint when specified', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
titlePrompt: 'Generate a concise title',
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
titlePrompt: 'Generate a concise title',
|
||||
},
|
||||
[EModelEndpoint.agents]: {
|
||||
titleEndpoint: 'custom-provider',
|
||||
titleMethod: 'structured',
|
||||
},
|
||||
},
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: false,
|
||||
capabilities: [AgentCapabilities.tools],
|
||||
maxCitations: 30,
|
||||
maxCitationsPerFile: 7,
|
||||
minRelevanceScore: 0.45,
|
||||
titleEndpoint: 'custom-provider',
|
||||
titleMethod: 'structured',
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -612,25 +736,27 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure all endpoint when specified', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
endpoints: {
|
||||
all: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Default title prompt for all endpoints',
|
||||
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
streamRate: 50,
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
all: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Default title prompt for all endpoints',
|
||||
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
streamRate: 50,
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
},
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -657,7 +783,8 @@ describe('AppService', () => {
|
||||
});
|
||||
|
||||
describe('AppService updating app config and issuing warnings', () => {
|
||||
let initialEnv: NodeJS.ProcessEnv;
|
||||
let initialEnv;
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
|
||||
beforeEach(() => {
|
||||
// Store initial environment variables to restore them after each test
|
||||
@@ -672,13 +799,15 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
process.env = { ...initialEnv };
|
||||
});
|
||||
|
||||
it('should initialize app config with default values if config is empty', async () => {
|
||||
const config = {};
|
||||
it('should initialize app config with default values if loadCustomConfig returns undefined', async () => {
|
||||
// Mock loadCustomConfig to return undefined
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined));
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
paths: expect.anything(),
|
||||
config: {},
|
||||
fileStrategy: FileSources.local,
|
||||
registration: expect.objectContaining({
|
||||
@@ -692,10 +821,10 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize app config with values from config', async () => {
|
||||
it('should initialize app config with values from loadCustomConfig', async () => {
|
||||
// Mock loadCustomConfig to return a specific config object with a complete balance config
|
||||
const config: Partial<TCustomConfig> = {
|
||||
fileStrategy: FileSources.firebase,
|
||||
const customConfig = {
|
||||
fileStrategy: 'firebase',
|
||||
registration: { socialLogins: ['testLogin'] },
|
||||
balance: {
|
||||
enabled: false,
|
||||
@@ -706,28 +835,27 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
refillAmount: 5000,
|
||||
},
|
||||
};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig));
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
fileStrategy: config.fileStrategy,
|
||||
paths: expect.anything(),
|
||||
config: customConfig,
|
||||
fileStrategy: customConfig.fileStrategy,
|
||||
registration: expect.objectContaining({
|
||||
socialLogins: config.registration?.socialLogins,
|
||||
socialLogins: customConfig.registration.socialLogins,
|
||||
}),
|
||||
balance: config.balance,
|
||||
balance: customConfig.balance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply the assistants endpoint configuration correctly to app config', async () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
const mockConfig = {
|
||||
endpoints: {
|
||||
assistants: {
|
||||
version: 'v2',
|
||||
retrievalModels: ['gpt-4', 'gpt-3.5-turbo'],
|
||||
capabilities: [],
|
||||
disableBuilder: true,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMs: 30000,
|
||||
@@ -735,8 +863,9 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -755,22 +884,119 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
expect(result.endpoints.assistants.excludedIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log a warning when both supportedIds and excludedIds are provided', async () => {
|
||||
const mockConfig = {
|
||||
endpoints: {
|
||||
assistants: {
|
||||
disableBuilder: false,
|
||||
pollIntervalMs: 3000,
|
||||
timeoutMs: 20000,
|
||||
supportedIds: ['id1', 'id2'],
|
||||
excludedIds: ['id3'],
|
||||
},
|
||||
},
|
||||
};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
await AppService();
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a warning when privateAssistants and supportedIds or excludedIds are provided', async () => {
|
||||
const mockConfig = {
|
||||
endpoints: {
|
||||
assistants: {
|
||||
privateAssistants: true,
|
||||
supportedIds: ['id1'],
|
||||
},
|
||||
},
|
||||
};
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
await AppService();
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => {
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
deprecatedAzureVariables.forEach((varInfo) => {
|
||||
process.env[varInfo.key] = 'test';
|
||||
});
|
||||
|
||||
await AppService();
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
deprecatedAzureVariables.forEach(({ key, description }) => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => {
|
||||
loadCustomConfig.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
conflictingAzureVariables.forEach((varInfo) => {
|
||||
process.env[varInfo.key] = 'test';
|
||||
});
|
||||
|
||||
await AppService();
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
conflictingAzureVariables.forEach(({ key }) => {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not parse environment variable references in OCR config', async () => {
|
||||
// Mock custom configuration with env variable references in OCR config
|
||||
const config: Partial<TCustomConfig> = {
|
||||
const mockConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}',
|
||||
baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}',
|
||||
strategy: OCRStrategy.MISTRAL_OCR,
|
||||
strategy: 'mistral_ocr',
|
||||
mistralModel: 'mistral-medium',
|
||||
},
|
||||
};
|
||||
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
// Set actual environment variables with different values
|
||||
process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key';
|
||||
process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com';
|
||||
|
||||
const result = await AppService({ config });
|
||||
const result = await AppService();
|
||||
|
||||
// Verify that the raw string references were preserved and not interpolated
|
||||
expect(result).toEqual(
|
||||
@@ -786,7 +1012,7 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
});
|
||||
|
||||
it('should correctly configure peoplePicker permissions when specified', async () => {
|
||||
const config = {
|
||||
const mockConfig = {
|
||||
interface: {
|
||||
peoplePicker: {
|
||||
users: true,
|
||||
@@ -796,7 +1022,9 @@ describe('AppService updating app config and issuing warnings', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await AppService({ config });
|
||||
loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig));
|
||||
|
||||
const result = await AppService();
|
||||
|
||||
// Check that interface config includes the permissions
|
||||
expect(result).toEqual(
|
||||
@@ -1,10 +0,0 @@
|
||||
const { ToolCacheKeys } = require('../getCachedTools');
|
||||
|
||||
describe('getCachedTools - Cache Isolation Security', () => {
|
||||
describe('ToolCacheKeys.MCP_SERVER', () => {
|
||||
it('should generate cache keys that include userId', () => {
|
||||
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
|
||||
expect(key).toBe('tools:mcp:user123:github');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,11 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { logger, AppService } = require('@librechat/data-schemas');
|
||||
const { loadAndFormatTools } = require('~/server/services/start/tools');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
const AppService = require('~/server/services/AppService');
|
||||
const { setCachedTools } = require('./getCachedTools');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
const BASE_CONFIG_KEY = '_BASE_';
|
||||
|
||||
const loadBaseConfig = async () => {
|
||||
/** @type {TCustomConfig} */
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
/** @type {Record<string, FunctionTool>} */
|
||||
const systemTools = loadAndFormatTools({
|
||||
adminFilter: config.filteredTools,
|
||||
adminIncluded: config.includedTools,
|
||||
directory: paths.structuredTools,
|
||||
});
|
||||
return AppService({ config, paths, systemTools });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the app configuration based on user context
|
||||
* @param {Object} [options]
|
||||
@@ -43,7 +29,7 @@ async function getAppConfig(options = {}) {
|
||||
let baseConfig = await cache.get(BASE_CONFIG_KEY);
|
||||
if (!baseConfig) {
|
||||
logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...');
|
||||
baseConfig = await loadBaseConfig();
|
||||
baseConfig = await AppService();
|
||||
|
||||
if (!baseConfig) {
|
||||
throw new Error('Failed to initialize app configuration through AppService.');
|
||||
|
||||
@@ -7,25 +7,24 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
const ToolCacheKeys = {
|
||||
/** Global tools available to all users */
|
||||
GLOBAL: 'tools:global',
|
||||
/** MCP tools cached by user ID and server name */
|
||||
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
|
||||
/** MCP tools cached by server name */
|
||||
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves available tools from cache
|
||||
* @function getCachedTools
|
||||
* @param {Object} options - Options for retrieving tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to get cached tools for
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
|
||||
*/
|
||||
async function getCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName } = options;
|
||||
const { serverName } = options;
|
||||
|
||||
// Return MCP server-specific tools if requested
|
||||
if (serverName && userId) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
// Default to global tools
|
||||
@@ -37,18 +36,17 @@ async function getCachedTools(options = {}) {
|
||||
* @function setCachedTools
|
||||
* @param {Object} tools - The tools object to cache
|
||||
* @param {Object} options - Options for caching tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name for server-specific tools
|
||||
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||
* @returns {Promise<boolean>} Whether the operation was successful
|
||||
*/
|
||||
async function setCachedTools(tools, options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, ttl } = options;
|
||||
const { serverName, ttl } = options;
|
||||
|
||||
// Cache by MCP server if specified (requires userId)
|
||||
if (serverName && userId) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
|
||||
// Cache by MCP server if specified
|
||||
if (serverName) {
|
||||
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
|
||||
}
|
||||
|
||||
// Default to global cache
|
||||
@@ -59,14 +57,13 @@ async function setCachedTools(tools, options = {}) {
|
||||
* Invalidates cached tools
|
||||
* @function invalidateCachedTools
|
||||
* @param {Object} options - Options for invalidating tools
|
||||
* @param {string} [options.userId] - User ID for user-specific MCP tools
|
||||
* @param {string} [options.serverName] - MCP server name to invalidate
|
||||
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function invalidateCachedTools(options = {}) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const { userId, serverName, invalidateGlobal = false } = options;
|
||||
const { serverName, invalidateGlobal = false } = options;
|
||||
|
||||
const keysToDelete = [];
|
||||
|
||||
@@ -74,23 +71,22 @@ async function invalidateCachedTools(options = {}) {
|
||||
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||
}
|
||||
|
||||
if (serverName && userId) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
if (serverName) {
|
||||
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
}
|
||||
|
||||
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP tools for a specific server from cache
|
||||
* Gets MCP tools for a specific server from cache or merges with global tools
|
||||
* @function getMCPServerTools
|
||||
* @param {string} userId - The user ID
|
||||
* @param {string} serverName - The MCP server name
|
||||
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
|
||||
*/
|
||||
async function getMCPServerTools(userId, serverName) {
|
||||
async function getMCPServerTools(serverName) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
|
||||
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
|
||||
|
||||
if (serverTools) {
|
||||
return serverTools;
|
||||
|
||||
48
api/server/services/Config/handleRateLimits.js
Normal file
48
api/server/services/Config/handleRateLimits.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const { RateLimitPrefix } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TCustomConfig['rateLimits'] | undefined} rateLimits
|
||||
*/
|
||||
const handleRateLimits = (rateLimits) => {
|
||||
if (!rateLimits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rateLimitKeys = {
|
||||
fileUploads: RateLimitPrefix.FILE_UPLOAD,
|
||||
conversationsImport: RateLimitPrefix.IMPORT,
|
||||
tts: RateLimitPrefix.TTS,
|
||||
stt: RateLimitPrefix.STT,
|
||||
};
|
||||
|
||||
Object.entries(rateLimitKeys).forEach(([key, prefix]) => {
|
||||
const rateLimit = rateLimits[key];
|
||||
if (rateLimit) {
|
||||
setRateLimitEnvVars(prefix, rateLimit);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set environment variables for rate limit configurations
|
||||
*
|
||||
* @param {string} prefix - Prefix for environment variable names
|
||||
* @param {object} rateLimit - Rate limit configuration object
|
||||
*/
|
||||
const setRateLimitEnvVars = (prefix, rateLimit) => {
|
||||
const envVarsMapping = {
|
||||
ipMax: `${prefix}_IP_MAX`,
|
||||
ipWindowInMinutes: `${prefix}_IP_WINDOW`,
|
||||
userMax: `${prefix}_USER_MAX`,
|
||||
userWindowInMinutes: `${prefix}_USER_WINDOW`,
|
||||
};
|
||||
|
||||
Object.entries(envVarsMapping).forEach(([key, envVar]) => {
|
||||
if (rateLimit[key] !== undefined) {
|
||||
process.env[envVar] = rateLimit[key];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = handleRateLimits;
|
||||
@@ -57,7 +57,7 @@ async function loadConfigModels(req) {
|
||||
|
||||
for (let i = 0; i < customEndpoints.length; i++) {
|
||||
const endpoint = customEndpoints[i];
|
||||
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
|
||||
const { models, name: configName, baseURL, apiKey } = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
endpointsMap[name] = endpoint;
|
||||
|
||||
@@ -76,8 +76,6 @@ async function loadConfigModels(req) {
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL,
|
||||
user: req.user.id,
|
||||
userObject: req.user,
|
||||
headers: endpointHeaders,
|
||||
direct: endpoint.directEndpoint,
|
||||
userIdQuery: models.userIdQuery,
|
||||
});
|
||||
@@ -87,9 +85,7 @@ async function loadConfigModels(req) {
|
||||
}
|
||||
|
||||
if (Array.isArray(models.default)) {
|
||||
modelsConfig[name] = models.default.map((model) =>
|
||||
typeof model === 'string' ? model : model.name,
|
||||
);
|
||||
modelsConfig[name] = models.default;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,8 +254,8 @@ describe('loadConfigModels', () => {
|
||||
// For groq and ollama, since the apiKey is "user_provided", models should not be fetched
|
||||
// Depending on your implementation's behavior regarding "default" models without fetching,
|
||||
// you may need to adjust the following assertions:
|
||||
expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default);
|
||||
expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default);
|
||||
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default);
|
||||
expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default);
|
||||
|
||||
// Verifying fetchModels was not called for groq and ollama
|
||||
expect(fetchModels).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,12 +5,14 @@ const keyBy = require('lodash/keyBy');
|
||||
const { loadYaml } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
CacheKeys,
|
||||
configSchema,
|
||||
paramSettings,
|
||||
EImageOutputType,
|
||||
agentParamSettings,
|
||||
validateSettingDefinitions,
|
||||
} = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
|
||||
@@ -117,6 +119,7 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
.filter((endpoint) => endpoint.customParams)
|
||||
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
|
||||
|
||||
|
||||
if (result.data.modelSpecs) {
|
||||
customConfig.modelSpecs = result.data.modelSpecs;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ const { getLogStores } = require('~/cache');
|
||||
/**
|
||||
* Updates MCP tools in the cache for a specific server
|
||||
* @param {Object} params - Parameters for updating MCP tools
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName - MCP server name
|
||||
* @param {Array} params.tools - Array of tool objects from MCP server
|
||||
* @returns {Promise<LCAvailableTools>}
|
||||
*/
|
||||
async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
async function updateMCPServerTools({ serverName, tools }) {
|
||||
try {
|
||||
const serverTools = {};
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
@@ -28,16 +27,14 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
|
||||
};
|
||||
}
|
||||
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug(
|
||||
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
|
||||
);
|
||||
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
|
||||
return serverTools;
|
||||
} catch (error) {
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -68,22 +65,21 @@ async function mergeAppTools(appTools) {
|
||||
/**
|
||||
* Caches MCP server tools (no longer merges with global)
|
||||
* @param {object} params
|
||||
* @param {string} params.userId - User ID for user-specific caching
|
||||
* @param {string} params.serverName
|
||||
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
|
||||
async function cacheMCPServerTools({ serverName, serverTools }) {
|
||||
try {
|
||||
const count = Object.keys(serverTools).length;
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
// Only cache server-specific tools, no merging with global
|
||||
await setCachedTools(serverTools, { userId, serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
|
||||
await setCachedTools(serverTools, { serverName });
|
||||
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
|
||||
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,16 +134,16 @@ const initializeAgent = async ({
|
||||
});
|
||||
|
||||
const tokensModel =
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
|
||||
const maxOutputTokens = optionalChainWithEmptyCheck(
|
||||
options.llmConfig?.maxOutputTokens,
|
||||
options.llmConfig?.maxTokens,
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
|
||||
const maxTokens = optionalChainWithEmptyCheck(
|
||||
modelOptions.maxOutputTokens,
|
||||
modelOptions.maxTokens,
|
||||
0,
|
||||
);
|
||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||
maxContextTokens,
|
||||
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
|
||||
18000,
|
||||
4096,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -203,7 +203,7 @@ const initializeAgent = async ({
|
||||
userMCPAuthMap,
|
||||
toolContextMap,
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
|
||||
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
|
||||
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
|
||||
const agentPromise = loadAgent({
|
||||
req,
|
||||
spec,
|
||||
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
@@ -21,6 +20,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
|
||||
endpoint,
|
||||
agent_id,
|
||||
endpointType,
|
||||
instructions,
|
||||
model_parameters,
|
||||
agent: agentPromise,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const {
|
||||
resolveHeaders,
|
||||
isUserProvided,
|
||||
@@ -142,27 +143,39 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
|
||||
if (optionsOnly) {
|
||||
const modelOptions = endpointOption?.model_parameters ?? {};
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
if (endpoint !== Providers.OLLAMA) {
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
modelOptions,
|
||||
},
|
||||
clientOptions,
|
||||
);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
{
|
||||
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
|
||||
},
|
||||
];
|
||||
return options;
|
||||
|
||||
if (clientOptions.reverseProxyUrl) {
|
||||
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
|
||||
delete clientOptions.reverseProxyUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
useLegacyContent: true,
|
||||
llmConfig: modelOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const client = new OpenAIClient(apiKey, clientOptions);
|
||||
|
||||
@@ -143,7 +143,7 @@ const initializeClient = async ({
|
||||
modelOptions.model = modelName;
|
||||
clientOptions = Object.assign({ modelOptions }, clientOptions);
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
const options = getOpenAIConfig(apiKey, clientOptions);
|
||||
if (options != null && serverless === true) {
|
||||
options.useLegacyContent = true;
|
||||
}
|
||||
|
||||
@@ -42,26 +42,18 @@ async function getCustomConfigSpeech(req, res) {
|
||||
settings.advancedMode = speechTab.advancedMode;
|
||||
}
|
||||
|
||||
if (speechTab.speechToText !== undefined) {
|
||||
if (typeof speechTab.speechToText === 'boolean') {
|
||||
settings.speechToText = speechTab.speechToText;
|
||||
} else {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
if (speechTab.speechToText) {
|
||||
for (const key in speechTab.speechToText) {
|
||||
if (speechTab.speechToText[key] !== undefined) {
|
||||
settings[key] = speechTab.speechToText[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (speechTab.textToSpeech !== undefined) {
|
||||
if (typeof speechTab.textToSpeech === 'boolean') {
|
||||
settings.textToSpeech = speechTab.textToSpeech;
|
||||
} else {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
if (speechTab.textToSpeech) {
|
||||
for (const key in speechTab.textToSpeech) {
|
||||
if (speechTab.textToSpeech[key] !== undefined) {
|
||||
settings[key] = speechTab.textToSpeech[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ const mime = require('mime');
|
||||
const axios = require('axios');
|
||||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getAzureContainerClient } = require('@librechat/api');
|
||||
const { getAzureContainerClient } = require('./initialize');
|
||||
|
||||
const defaultBasePath = 'images';
|
||||
const { AZURE_STORAGE_PUBLIC_ACCESS = 'true', AZURE_CONTAINER_NAME = 'files' } = process.env;
|
||||
@@ -30,7 +30,7 @@ async function saveBufferToAzure({
|
||||
containerName,
|
||||
}) {
|
||||
try {
|
||||
const containerClient = await getAzureContainerClient(containerName);
|
||||
const containerClient = getAzureContainerClient(containerName);
|
||||
const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined;
|
||||
// Create the container if it doesn't exist. This is done per operation.
|
||||
await containerClient.createIfNotExists({ access });
|
||||
@@ -84,7 +84,7 @@ async function saveURLToAzure({
|
||||
*/
|
||||
async function getAzureURL({ fileName, basePath = defaultBasePath, userId, containerName }) {
|
||||
try {
|
||||
const containerClient = await getAzureContainerClient(containerName);
|
||||
const containerClient = getAzureContainerClient(containerName);
|
||||
const blobPath = userId ? `${basePath}/${userId}/${fileName}` : `${basePath}/${fileName}`;
|
||||
const blockBlobClient = containerClient.getBlockBlobClient(blobPath);
|
||||
return blockBlobClient.url;
|
||||
@@ -103,7 +103,7 @@ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, conta
|
||||
*/
|
||||
async function deleteFileFromAzure(req, file) {
|
||||
try {
|
||||
const containerClient = await getAzureContainerClient(AZURE_CONTAINER_NAME);
|
||||
const containerClient = getAzureContainerClient(AZURE_CONTAINER_NAME);
|
||||
const blobPath = file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1];
|
||||
if (!blobPath.includes(req.user.id)) {
|
||||
throw new Error('User ID not found in blob path');
|
||||
@@ -140,7 +140,7 @@ async function streamFileToAzure({
|
||||
containerName,
|
||||
}) {
|
||||
try {
|
||||
const containerClient = await getAzureContainerClient(containerName);
|
||||
const containerClient = getAzureContainerClient(containerName);
|
||||
const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined;
|
||||
|
||||
// Create the container if it doesn't exist
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const crud = require('./crud');
|
||||
const images = require('./images');
|
||||
const initialize = require('./initialize');
|
||||
|
||||
module.exports = {
|
||||
...crud,
|
||||
...images,
|
||||
...initialize,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
import type { ContainerClient, BlobServiceClient } from '@azure/storage-blob';
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { BlobServiceClient } = require('@azure/storage-blob');
|
||||
|
||||
let blobServiceClient: BlobServiceClient | null = null;
|
||||
let blobServiceClient = null;
|
||||
let azureWarningLogged = false;
|
||||
|
||||
/**
|
||||
@@ -10,18 +9,18 @@ let azureWarningLogged = false;
|
||||
* This function establishes a connection by checking if a connection string is provided.
|
||||
* If available, the connection string is used; otherwise, Managed Identity (via DefaultAzureCredential) is utilized.
|
||||
* Note: Container creation (and its public access settings) is handled later in the CRUD functions.
|
||||
* @returns The initialized client, or null if the required configuration is missing.
|
||||
* @returns {BlobServiceClient|null} The initialized client, or null if the required configuration is missing.
|
||||
*/
|
||||
export const initializeAzureBlobService = async (): Promise<BlobServiceClient | null> => {
|
||||
const initializeAzureBlobService = () => {
|
||||
if (blobServiceClient) {
|
||||
return blobServiceClient;
|
||||
}
|
||||
const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING;
|
||||
if (connectionString) {
|
||||
const { BlobServiceClient } = await import('@azure/storage-blob');
|
||||
blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
|
||||
logger.info('Azure Blob Service initialized using connection string');
|
||||
} else {
|
||||
const { DefaultAzureCredential } = require('@azure/identity');
|
||||
const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
|
||||
if (!accountName) {
|
||||
if (!azureWarningLogged) {
|
||||
@@ -34,7 +33,6 @@ export const initializeAzureBlobService = async (): Promise<BlobServiceClient |
|
||||
}
|
||||
const url = `https://${accountName}.blob.core.windows.net`;
|
||||
const credential = new DefaultAzureCredential();
|
||||
const { BlobServiceClient } = await import('@azure/storage-blob');
|
||||
blobServiceClient = new BlobServiceClient(url, credential);
|
||||
logger.info('Azure Blob Service initialized using Managed Identity');
|
||||
}
|
||||
@@ -43,12 +41,15 @@ export const initializeAzureBlobService = async (): Promise<BlobServiceClient |
|
||||
|
||||
/**
|
||||
* Retrieves the Azure ContainerClient for the given container name.
|
||||
* @param [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name.
|
||||
* @returns The Azure ContainerClient.
|
||||
* @param {string} [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name.
|
||||
* @returns {ContainerClient|null} The Azure ContainerClient.
|
||||
*/
|
||||
export const getAzureContainerClient = async (
|
||||
containerName = process.env.AZURE_CONTAINER_NAME || 'files',
|
||||
): Promise<ContainerClient | null> => {
|
||||
const serviceClient = await initializeAzureBlobService();
|
||||
const getAzureContainerClient = (containerName = process.env.AZURE_CONTAINER_NAME || 'files') => {
|
||||
const serviceClient = initializeAzureBlobService();
|
||||
return serviceClient ? serviceClient.getContainerClient(containerName) : null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initializeAzureBlobService,
|
||||
getAzureContainerClient,
|
||||
};
|
||||
@@ -3,9 +3,9 @@ const path = require('path');
|
||||
const axios = require('axios');
|
||||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getFirebaseStorage } = require('@librechat/api');
|
||||
const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage');
|
||||
const { getBufferMetadata } = require('~/server/utils');
|
||||
const { getFirebaseStorage } = require('./initialize');
|
||||
|
||||
/**
|
||||
* Deletes a file from Firebase Storage.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const crud = require('./crud');
|
||||
const images = require('./images');
|
||||
const initialize = require('./initialize');
|
||||
|
||||
module.exports = {
|
||||
...crud,
|
||||
...images,
|
||||
...initialize,
|
||||
};
|
||||
|
||||
39
api/server/services/Files/Firebase/initialize.js
Normal file
39
api/server/services/Files/Firebase/initialize.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const firebase = require('firebase/app');
|
||||
const { getStorage } = require('firebase/storage');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
let i = 0;
|
||||
let firebaseApp = null;
|
||||
|
||||
const initializeFirebase = () => {
|
||||
// Return existing instance if already initialized
|
||||
if (firebaseApp) {
|
||||
return firebaseApp;
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.FIREBASE_API_KEY,
|
||||
authDomain: process.env.FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
if (Object.values(firebaseConfig).some((value) => !value)) {
|
||||
i === 0 && logger.info('[Optional] CDN not initialized.');
|
||||
i++;
|
||||
return null;
|
||||
}
|
||||
|
||||
firebaseApp = firebase.initializeApp(firebaseConfig);
|
||||
logger.info('Firebase CDN initialized');
|
||||
return firebaseApp;
|
||||
};
|
||||
|
||||
const getFirebaseStorage = () => {
|
||||
const app = initializeFirebase();
|
||||
return app ? getStorage(app) : null;
|
||||
};
|
||||
|
||||
module.exports = { initializeFirebase, getFirebaseStorage };
|
||||
@@ -4,7 +4,6 @@ const axios = require('axios');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { generateShortLivedToken } = require('@librechat/api');
|
||||
const { resizeImageBuffer } = require('~/server/services/Files/images/resize');
|
||||
const { getBufferMetadata } = require('~/server/utils');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
@@ -287,18 +286,7 @@ async function uploadLocalFile({ req, file, file_id }) {
|
||||
await fs.promises.writeFile(newPath, inputBuffer);
|
||||
const filepath = path.posix.join('/', 'uploads', req.user.id, path.basename(newPath));
|
||||
|
||||
let height, width;
|
||||
if (file.mimetype && file.mimetype.startsWith('image/')) {
|
||||
try {
|
||||
const { width: imgWidth, height: imgHeight } = await resizeImageBuffer(inputBuffer, 'high');
|
||||
height = imgHeight;
|
||||
width = imgWidth;
|
||||
} catch (error) {
|
||||
logger.warn('[uploadLocalFile] Could not get image dimensions:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { filepath, bytes, height, width };
|
||||
return { filepath, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
const fs = require('fs');
|
||||
const fetch = require('node-fetch');
|
||||
const { initializeS3 } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const {
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const { initializeS3 } = require('./initialize');
|
||||
|
||||
const bucketName = process.env.AWS_BUCKET_NAME;
|
||||
const defaultBasePath = 'images';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const crud = require('./crud');
|
||||
const images = require('./images');
|
||||
const initialize = require('./initialize');
|
||||
|
||||
module.exports = {
|
||||
...crud,
|
||||
...images,
|
||||
...initialize,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
const { S3Client } = require('@aws-sdk/client-s3');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
let s3: S3Client | null = null;
|
||||
let s3 = null;
|
||||
|
||||
/**
|
||||
* Initializes and returns an instance of the AWS S3 client.
|
||||
@@ -11,9 +11,9 @@ let s3: S3Client | null = null;
|
||||
*
|
||||
* If AWS_ENDPOINT_URL is provided, it will be used as the endpoint.
|
||||
*
|
||||
* @returns An instance of S3Client if the region is provided; otherwise, null.
|
||||
* @returns {S3Client|null} An instance of S3Client if the region is provided; otherwise, null.
|
||||
*/
|
||||
export const initializeS3 = (): S3Client | null => {
|
||||
const initializeS3 = () => {
|
||||
if (s3) {
|
||||
return s3;
|
||||
}
|
||||
@@ -49,3 +49,5 @@ export const initializeS3 = (): S3Client | null => {
|
||||
|
||||
return s3;
|
||||
};
|
||||
|
||||
module.exports = { initializeS3 };
|
||||
@@ -1,14 +1,16 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api');
|
||||
const {
|
||||
FileSources,
|
||||
VisionModes,
|
||||
ImageDetail,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
} = require('librechat-data-provider');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const countTokens = require('~/server/utils/countTokens');
|
||||
|
||||
/**
|
||||
* Converts a readable stream to a base64 encoded string.
|
||||
@@ -86,14 +88,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
|
||||
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
||||
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
||||
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
||||
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||
* @returns {Promise<{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||
*/
|
||||
async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
const promises = [];
|
||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
||||
const encodingMethods = {};
|
||||
/** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
|
||||
/** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
|
||||
const result = {
|
||||
text: '',
|
||||
files: [],
|
||||
image_urls: [],
|
||||
};
|
||||
@@ -102,9 +105,29 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const fileTokenLimit =
|
||||
req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit;
|
||||
|
||||
for (let file of files) {
|
||||
/** @type {FileSources} */
|
||||
const source = file.source ?? FileSources.local;
|
||||
if (source === FileSources.text && file.text) {
|
||||
let fileText = file.text;
|
||||
|
||||
const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({
|
||||
text: fileText,
|
||||
tokenLimit: fileTokenLimit,
|
||||
tokenCountFn: (text) => countTokens(text),
|
||||
});
|
||||
|
||||
if (wasTruncated) {
|
||||
logger.debug(
|
||||
`[encodeAndFormat] Text content truncated for file: ${file.filename} due to token limits`,
|
||||
);
|
||||
}
|
||||
|
||||
result.text += `${!result.text ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`;
|
||||
}
|
||||
|
||||
if (!file.height) {
|
||||
promises.push([file, null]);
|
||||
@@ -142,6 +165,10 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||
promises.push(preparePayload(req, file));
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
result.text += '\n```';
|
||||
}
|
||||
|
||||
const detail = req.body.imageDetail ?? ImageDetail.auto;
|
||||
|
||||
/** @type {Array<[MongoFile, string]>} */
|
||||
|
||||
@@ -508,10 +508,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
const { file } = req;
|
||||
const appConfig = req.config;
|
||||
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
|
||||
|
||||
let messageAttachment = !!metadata.message_file;
|
||||
|
||||
if (agent_id && !tool_resource && !messageAttachment) {
|
||||
if (agent_id && !tool_resource) {
|
||||
throw new Error('No tool resource provided for agent file upload');
|
||||
}
|
||||
|
||||
@@ -519,11 +516,17 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
throw new Error('Image uploads are not supported for file search tool resources');
|
||||
}
|
||||
|
||||
let messageAttachment = !!metadata.message_file;
|
||||
if (!messageAttachment && !agent_id) {
|
||||
throw new Error('No agent ID provided for agent file upload');
|
||||
}
|
||||
|
||||
const isImage = file.mimetype.startsWith('image');
|
||||
if (!isImage && !tool_resource) {
|
||||
/** Note: this needs to be removed when we can support files to providers */
|
||||
throw new Error('No tool resource provided for non-image agent file upload');
|
||||
}
|
||||
|
||||
let fileInfoMetadata;
|
||||
const entity_id = messageAttachment === true ? undefined : agent_id;
|
||||
const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads';
|
||||
@@ -598,22 +601,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||
if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
|
||||
throw new Error('OCR capability is not enabled for Agents');
|
||||
} else if (shouldUseOCR) {
|
||||
try {
|
||||
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
|
||||
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
|
||||
);
|
||||
const {
|
||||
text,
|
||||
bytes,
|
||||
filepath: ocrFileURL,
|
||||
} = await uploadOCR({ req, file, loadAuthValues });
|
||||
return await createTextFile({ text, bytes, filepath: ocrFileURL });
|
||||
} catch (ocrError) {
|
||||
logger.error(
|
||||
`[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`,
|
||||
ocrError,
|
||||
);
|
||||
}
|
||||
const { handleFileUpload: uploadOCR } = getStrategyFunctions(
|
||||
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
|
||||
);
|
||||
const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues });
|
||||
return await createTextFile({ text, bytes, filepath: ocrFileURL });
|
||||
}
|
||||
|
||||
const shouldUseSTT = fileConfig.checkType(
|
||||
|
||||
@@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
|
||||
/**
|
||||
* Get current user's Entra ID group memberships from Microsoft Graph
|
||||
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
|
||||
* Uses /me/memberOf endpoint to get groups the user is a member of
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
@@ -167,12 +167,10 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
|
||||
const getUserEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const response = await graphClient
|
||||
.api('/me/getMemberGroups')
|
||||
.post({ securityEnabledOnly: false });
|
||||
|
||||
const groupIds = Array.isArray(response?.value) ? response.value : [];
|
||||
return [...new Set(groupIds.map((groupId) => String(groupId)))];
|
||||
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
||||
return [];
|
||||
@@ -189,22 +187,13 @@ const getUserEntraGroups = async (accessToken, sub) => {
|
||||
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allGroupIds = [];
|
||||
let nextLink = '/me/ownedObjects/microsoft.graph.group';
|
||||
|
||||
while (nextLink) {
|
||||
const response = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
const groups = response?.value || [];
|
||||
allGroupIds.push(...groups.map((group) => group.id));
|
||||
const groupsResponse = await graphClient
|
||||
.api('/me/ownedObjects/microsoft.graph.group')
|
||||
.select('id')
|
||||
.get();
|
||||
|
||||
nextLink = response['@odata.nextLink']
|
||||
? response['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
: null;
|
||||
}
|
||||
|
||||
return allGroupIds;
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
||||
return [];
|
||||
@@ -222,27 +211,21 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allMembers = new Set();
|
||||
let nextLink = `/groups/${groupId}/transitiveMembers`;
|
||||
const allMembers = [];
|
||||
let nextLink = `/groups/${groupId}/members`;
|
||||
|
||||
while (nextLink) {
|
||||
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const members = membersResponse?.value || [];
|
||||
members.forEach((member) => {
|
||||
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
|
||||
allMembers.add(member.id);
|
||||
}
|
||||
});
|
||||
const members = membersResponse.value || [];
|
||||
allMembers.push(...members.map((member) => member.id));
|
||||
|
||||
nextLink = membersResponse['@odata.nextLink']
|
||||
? membersResponse['@odata.nextLink']
|
||||
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
|
||||
.trim() || null
|
||||
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return Array.from(allMembers);
|
||||
return allMembers;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||
return [];
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('GraphApiService', () => {
|
||||
header: jest.fn().mockReturnThis(),
|
||||
top: jest.fn().mockReturnThis(),
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
};
|
||||
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
@@ -515,33 +514,31 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
describe('getUserEntraGroups', () => {
|
||||
it('should fetch user groups using getMemberGroups endpoint', async () => {
|
||||
it('should fetch user groups from memberOf endpoint', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: ['group-1', 'group-2'],
|
||||
value: [
|
||||
{
|
||||
id: 'group-1',
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
|
||||
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
|
||||
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate returned group ids', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({
|
||||
value: ['group-1', 'group-2', 'group-1'],
|
||||
});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.post.mockRejectedValue(new Error('API error'));
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -553,7 +550,7 @@ describe('GraphApiService', () => {
|
||||
value: [],
|
||||
};
|
||||
|
||||
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -561,7 +558,7 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
|
||||
it('should handle missing value property', async () => {
|
||||
mockGraphClient.post.mockResolvedValue({});
|
||||
mockGraphClient.get.mockResolvedValue({});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
@@ -569,89 +566,6 @@ describe('GraphApiService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserOwnedEntraGroups', () => {
|
||||
it('should fetch owned groups with pagination support', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-1',
|
||||
},
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
};
|
||||
|
||||
const secondPage = {
|
||||
value: [
|
||||
{
|
||||
id: 'owned-group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/me/ownedObjects/microsoft.graph.group',
|
||||
);
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupMembers', () => {
|
||||
it('should fetch transitive members and include only users', async () => {
|
||||
const firstPage = {
|
||||
value: [
|
||||
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
|
||||
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
|
||||
],
|
||||
'@odata.nextLink':
|
||||
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
};
|
||||
const secondPage = {
|
||||
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
|
||||
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/groups/group-id/transitiveMembers?$skiptoken=abc',
|
||||
);
|
||||
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
|
||||
expect(result).toEqual(['user-1', 'user-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGraphApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -450,7 +450,7 @@ async function getMCPSetupData(userId) {
|
||||
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
||||
}
|
||||
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
||||
const oauthServers = mcpManager.getOAuthServers();
|
||||
const oauthServers = mcpManager.getOAuthServers() || new Set();
|
||||
|
||||
return {
|
||||
mcpConfig,
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||
const mockMCPManager = {
|
||||
appConnections: { getAll: jest.fn(() => null) },
|
||||
getUserConnections: jest.fn(() => null),
|
||||
getOAuthServers: jest.fn(() => new Set()),
|
||||
getOAuthServers: jest.fn(() => null),
|
||||
};
|
||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||
|
||||
|
||||
@@ -39,8 +39,6 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
|
||||
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
|
||||
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
|
||||
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
|
||||
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
|
||||
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
|
||||
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
|
||||
* @async
|
||||
*/
|
||||
@@ -54,8 +52,6 @@ const fetchModels = async ({
|
||||
userIdQuery = false,
|
||||
createTokenConfig = true,
|
||||
tokenKey,
|
||||
headers,
|
||||
userObject,
|
||||
}) => {
|
||||
let models = [];
|
||||
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
|
||||
@@ -69,13 +65,7 @@ const fetchModels = async ({
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
||||
try {
|
||||
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
||||
} catch (ollamaError) {
|
||||
const logMessage =
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
|
||||
logAxiosError({ message: logMessage, error: ollamaError });
|
||||
}
|
||||
return await OllamaClient.fetchModels(baseURL);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const axios = require('axios');
|
||||
const { logAxiosError, resolveHeaders } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
|
||||
|
||||
const {
|
||||
@@ -18,8 +18,6 @@ jest.mock('@librechat/api', () => {
|
||||
processModelData: jest.fn((...args) => {
|
||||
return originalUtils.processModelData(...args);
|
||||
}),
|
||||
logAxiosError: jest.fn(),
|
||||
resolveHeaders: jest.fn((options) => options?.headers || {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -279,51 +277,12 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass headers and user object to Ollama fetchModels', async () => {
|
||||
const customHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer custom-token',
|
||||
};
|
||||
const userObject = {
|
||||
id: 'user789',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
resolveHeaders.mockReturnValueOnce(customHeaders);
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'ollama',
|
||||
headers: customHeaders,
|
||||
userObject,
|
||||
});
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: customHeaders,
|
||||
user: userObject,
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: customHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
|
||||
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails', async () => {
|
||||
axios.get.mockRejectedValue(new Error('Network error'));
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
@@ -331,13 +290,8 @@ describe('fetchModels with Ollama specific logic', () => {
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
|
||||
expect(logAxiosError).toHaveBeenCalledWith({
|
||||
message:
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
expect(models).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return an empty array if no baseURL is provided', async () => {
|
||||
|
||||
@@ -98,7 +98,6 @@ async function reinitMCPServer({
|
||||
if (connection && !oauthRequired) {
|
||||
tools = await connection.fetchTools();
|
||||
availableTools = await updateMCPServerTools({
|
||||
userId: user.id,
|
||||
serverName,
|
||||
tools,
|
||||
});
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import logger from '~/config/winston';
|
||||
import {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Capabilities,
|
||||
EModelEndpoint,
|
||||
assistantEndpointSchema,
|
||||
defaultAssistantsVersion,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TAssistantEndpoint } from 'librechat-data-provider';
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled.
|
||||
* @returns The Assistants endpoint configuration.
|
||||
* @returns {Partial<TAssistantEndpoint>} The Assistants endpoint configuration.
|
||||
*/
|
||||
export function azureAssistantsDefaults(): {
|
||||
capabilities: TAssistantEndpoint['capabilities'];
|
||||
version: TAssistantEndpoint['version'];
|
||||
} {
|
||||
function azureAssistantsDefaults() {
|
||||
return {
|
||||
capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter],
|
||||
version: defaultAssistantsVersion.azureAssistants,
|
||||
@@ -23,26 +18,22 @@ export function azureAssistantsDefaults(): {
|
||||
|
||||
/**
|
||||
* Sets up the Assistants configuration from the config (`librechat.yaml`) file.
|
||||
* @param config - The loaded custom configuration.
|
||||
* @param assistantsEndpoint - The Assistants endpoint name.
|
||||
* @param {TCustomConfig} config - The loaded custom configuration.
|
||||
* @param {EModelEndpoint.assistants|EModelEndpoint.azureAssistants} assistantsEndpoint - The Assistants endpoint name.
|
||||
* - The previously loaded assistants configuration from Azure OpenAI Assistants option.
|
||||
* @param [prevConfig]
|
||||
* @returns The Assistants endpoint configuration.
|
||||
* @param {Partial<TAssistantEndpoint>} [prevConfig]
|
||||
* @returns {Partial<TAssistantEndpoint>} The Assistants endpoint configuration.
|
||||
*/
|
||||
export function assistantsConfigSetup(
|
||||
config: Partial<TCustomConfig>,
|
||||
assistantsEndpoint: EModelEndpoint.assistants | EModelEndpoint.azureAssistants,
|
||||
prevConfig: Partial<TAssistantEndpoint> = {},
|
||||
): Partial<TAssistantEndpoint> {
|
||||
const assistantsConfig = config.endpoints?.[assistantsEndpoint];
|
||||
function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) {
|
||||
const assistantsConfig = config.endpoints[assistantsEndpoint];
|
||||
const parsedConfig = assistantEndpointSchema.parse(assistantsConfig);
|
||||
if (assistantsConfig?.supportedIds?.length && assistantsConfig.excludedIds?.length) {
|
||||
if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) {
|
||||
logger.warn(
|
||||
`Configuration conflict: The '${assistantsEndpoint}' endpoint has both 'supportedIds' and 'excludedIds' defined. The 'excludedIds' will be ignored.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
assistantsConfig?.privateAssistants &&
|
||||
assistantsConfig.privateAssistants &&
|
||||
(assistantsConfig.supportedIds?.length || assistantsConfig.excludedIds?.length)
|
||||
) {
|
||||
logger.warn(
|
||||
@@ -68,3 +59,5 @@ export function assistantsConfigSetup(
|
||||
titlePromptTemplate: parsedConfig.titlePromptTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { azureAssistantsDefaults, assistantsConfigSetup };
|
||||
@@ -1,22 +1,18 @@
|
||||
import logger from '~/config/winston';
|
||||
import {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
EModelEndpoint,
|
||||
validateAzureGroups,
|
||||
mapModelToAzureConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TAzureConfig } from 'librechat-data-provider';
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file.
|
||||
* @param config - The loaded custom configuration.
|
||||
* @returns The Azure OpenAI configuration.
|
||||
* @param {TCustomConfig} config - The loaded custom configuration.
|
||||
* @returns {TAzureConfig} The Azure OpenAI configuration.
|
||||
*/
|
||||
export function azureConfigSetup(config: Partial<TCustomConfig>): TAzureConfig {
|
||||
const azureConfig = config.endpoints?.[EModelEndpoint.azureOpenAI];
|
||||
if (!azureConfig) {
|
||||
throw new Error('Azure OpenAI configuration is missing.');
|
||||
}
|
||||
const { groups, ...azureConfiguration } = azureConfig;
|
||||
function azureConfigSetup(config) {
|
||||
const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI];
|
||||
/** @type {TAzureConfigValidationResult} */
|
||||
const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups);
|
||||
|
||||
if (!isValid) {
|
||||
@@ -26,18 +22,16 @@ export function azureConfigSetup(config: Partial<TCustomConfig>): TAzureConfig {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const assistantModels: string[] = [];
|
||||
const assistantGroups = new Set<string>();
|
||||
const assistantModels = [];
|
||||
const assistantGroups = new Set();
|
||||
for (const modelName of modelNames) {
|
||||
mapModelToAzureConfig({ modelName, modelGroupMap, groupMap });
|
||||
const groupName = modelGroupMap?.[modelName]?.group;
|
||||
const modelGroup = groupMap?.[groupName];
|
||||
const supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants;
|
||||
let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants;
|
||||
if (supportsAssistants) {
|
||||
assistantModels.push(modelName);
|
||||
if (!assistantGroups.has(groupName)) {
|
||||
assistantGroups.add(groupName);
|
||||
}
|
||||
!assistantGroups.has(groupName) && assistantGroups.add(groupName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +53,13 @@ export function azureConfigSetup(config: Partial<TCustomConfig>): TAzureConfig {
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
isValid,
|
||||
groupMap,
|
||||
modelNames,
|
||||
modelGroupMap,
|
||||
groupMap,
|
||||
assistantModels,
|
||||
assistantGroups: Array.from(assistantGroups),
|
||||
...azureConfiguration,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { azureConfigSetup };
|
||||
@@ -1,9 +1,11 @@
|
||||
import { logger, webSearchKeys } from '@librechat/data-schemas';
|
||||
import { Constants, extractVariableName } from 'librechat-data-provider';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { isEnabled, checkEmailConfig } from '~/utils';
|
||||
import { handleRateLimits } from './limits';
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, webSearchKeys, checkEmailConfig } = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
extractVariableName,
|
||||
deprecatedAzureVariables,
|
||||
conflictingAzureVariables,
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
const secretDefaults = {
|
||||
CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0',
|
||||
@@ -30,84 +32,17 @@ const deprecatedVariables = [
|
||||
},
|
||||
];
|
||||
|
||||
export const deprecatedAzureVariables = [
|
||||
/* "related to" precedes description text */
|
||||
{ key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' },
|
||||
{ key: 'AZURE_OPENAI_MODELS', description: 'setting models' },
|
||||
{
|
||||
key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME',
|
||||
description: 'using model names as deployment names',
|
||||
},
|
||||
{ key: 'AZURE_API_KEY', description: 'setting a single Azure API key' },
|
||||
{ key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' },
|
||||
{
|
||||
key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME',
|
||||
description: 'setting a single Azure deployment name',
|
||||
},
|
||||
{ key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' },
|
||||
{
|
||||
key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME',
|
||||
description: 'setting a single Azure completions deployment name',
|
||||
},
|
||||
{
|
||||
key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME',
|
||||
description: 'setting a single Azure embeddings deployment name',
|
||||
},
|
||||
{
|
||||
key: 'PLUGINS_USE_AZURE',
|
||||
description: 'using Azure for Plugins',
|
||||
},
|
||||
];
|
||||
|
||||
export const conflictingAzureVariables = [
|
||||
{
|
||||
key: 'INSTANCE_NAME',
|
||||
},
|
||||
{
|
||||
key: 'DEPLOYMENT_NAME',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks the password reset configuration for security issues.
|
||||
*/
|
||||
function checkPasswordReset() {
|
||||
const emailEnabled = checkEmailConfig();
|
||||
const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET);
|
||||
|
||||
if (!emailEnabled && passwordResetAllowed) {
|
||||
logger.warn(
|
||||
`❗❗❗
|
||||
|
||||
Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured.
|
||||
|
||||
This setup is insecure as password reset links will be issued with a recognized email.
|
||||
|
||||
Please configure email service for secure password reset functionality.
|
||||
|
||||
https://www.librechat.ai/docs/configuration/authentication/email
|
||||
|
||||
❗❗❗`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks environment variables for default secrets and deprecated variables.
|
||||
* Logs warnings for any default secret values being used and for usage of deprecated variables.
|
||||
* Logs warnings for any default secret values being used and for usage of deprecated `GOOGLE_API_KEY`.
|
||||
* Advises on replacing default secrets and updating deprecated variables.
|
||||
* @param {Object} options
|
||||
* @param {Function} options.isEnabled - Function to check if a feature is enabled
|
||||
* @param {Function} options.checkEmailConfig - Function to check email configuration
|
||||
*/
|
||||
export function checkVariables() {
|
||||
function checkVariables() {
|
||||
let hasDefaultSecrets = false;
|
||||
for (const [key, value] of Object.entries(secretDefaults)) {
|
||||
if (process.env[key] === value) {
|
||||
logger.warn(`Default value for ${key} is being used.`);
|
||||
if (!hasDefaultSecrets) {
|
||||
hasDefaultSecrets = true;
|
||||
}
|
||||
!hasDefaultSecrets && (hasDefaultSecrets = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +69,7 @@ export function checkVariables() {
|
||||
* Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints.
|
||||
* Logs information or warning based on the API's availability and response.
|
||||
*/
|
||||
export async function checkHealth() {
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch(`${process.env.RAG_API_URL}/health`);
|
||||
if (response?.ok && response?.status === 200) {
|
||||
@@ -169,85 +104,11 @@ function checkAzureVariables() {
|
||||
});
|
||||
}
|
||||
|
||||
export function checkInterfaceConfig(appConfig: AppConfig) {
|
||||
const interfaceConfig = appConfig.interfaceConfig;
|
||||
let i = 0;
|
||||
const logSettings = () => {
|
||||
// log interface object and model specs object (without list) for reference
|
||||
logger.warn(`\`interface\` settings:\n${JSON.stringify(interfaceConfig, null, 2)}`);
|
||||
logger.warn(
|
||||
`\`modelSpecs\` settings:\n${JSON.stringify(
|
||||
{ ...(appConfig?.modelSpecs ?? {}), list: undefined },
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
};
|
||||
|
||||
// warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs.
|
||||
if (appConfig?.modelSpecs?.prioritize && interfaceConfig?.presets) {
|
||||
logger.warn(
|
||||
"Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.",
|
||||
);
|
||||
if (i === 0) i++;
|
||||
}
|
||||
|
||||
// warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
|
||||
if (
|
||||
appConfig?.modelSpecs?.enforce &&
|
||||
(interfaceConfig?.endpointsMenu ||
|
||||
interfaceConfig?.modelSelect ||
|
||||
interfaceConfig?.presets ||
|
||||
interfaceConfig?.parameters)
|
||||
) {
|
||||
logger.warn(
|
||||
"Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.",
|
||||
);
|
||||
if (i === 0) i++;
|
||||
}
|
||||
// warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior.
|
||||
if (appConfig?.modelSpecs?.enforce && !appConfig?.modelSpecs?.prioritize) {
|
||||
logger.warn(
|
||||
"Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.",
|
||||
);
|
||||
if (i === 0) i++;
|
||||
}
|
||||
|
||||
if (i > 0) {
|
||||
logSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs startup checks including environment variable validation and health checks.
|
||||
* This should be called during application startup before initializing services.
|
||||
* @param [appConfig] - The application configuration object.
|
||||
*/
|
||||
export async function performStartupChecks(appConfig?: AppConfig) {
|
||||
checkVariables();
|
||||
if (appConfig?.endpoints?.azureOpenAI) {
|
||||
checkAzureVariables();
|
||||
}
|
||||
if (appConfig) {
|
||||
checkInterfaceConfig(appConfig);
|
||||
}
|
||||
if (appConfig?.config) {
|
||||
checkConfig(appConfig.config);
|
||||
}
|
||||
if (appConfig?.config?.webSearch) {
|
||||
checkWebSearchConfig(appConfig.config.webSearch);
|
||||
}
|
||||
if (appConfig?.config?.rateLimits) {
|
||||
handleRateLimits(appConfig.config.rateLimits);
|
||||
}
|
||||
await checkHealth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs basic checks on the loaded config object.
|
||||
* @param config - The loaded custom configuration.
|
||||
* @param {TCustomConfig} config - The loaded custom configuration.
|
||||
*/
|
||||
export function checkConfig(config: Partial<TCustomConfig>) {
|
||||
function checkConfig(config) {
|
||||
if (config.version !== Constants.CONFIG_VERSION) {
|
||||
logger.info(
|
||||
`\nOutdated Config version: ${config.version}
|
||||
@@ -260,19 +121,40 @@ Latest version: ${Constants.CONFIG_VERSION}
|
||||
}
|
||||
}
|
||||
|
||||
function checkPasswordReset() {
|
||||
const emailEnabled = checkEmailConfig();
|
||||
const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET);
|
||||
|
||||
if (!emailEnabled && passwordResetAllowed) {
|
||||
logger.warn(
|
||||
`❗❗❗
|
||||
|
||||
Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured.
|
||||
|
||||
This setup is insecure as password reset links will be issued with a recognized email.
|
||||
|
||||
Please configure email service for secure password reset functionality.
|
||||
|
||||
https://www.librechat.ai/docs/configuration/authentication/email
|
||||
|
||||
❗❗❗`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks web search configuration values to ensure they are environment variable references.
|
||||
* Warns if actual API keys or URLs are used instead of environment variable references.
|
||||
* Logs debug information for properly configured environment variable references.
|
||||
* @param webSearchConfig - The loaded web search configuration object.
|
||||
* @param {Object} webSearchConfig - The loaded web search configuration object.
|
||||
*/
|
||||
export function checkWebSearchConfig(webSearchConfig?: Partial<TCustomConfig['webSearch']> | null) {
|
||||
function checkWebSearchConfig(webSearchConfig) {
|
||||
if (!webSearchConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
webSearchKeys.forEach((key) => {
|
||||
const value = webSearchConfig[key as keyof typeof webSearchConfig];
|
||||
const value = webSearchConfig[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const varName = extractVariableName(value);
|
||||
@@ -305,3 +187,11 @@ export function checkWebSearchConfig(webSearchConfig?: Partial<TCustomConfig['we
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkHealth,
|
||||
checkConfig,
|
||||
checkVariables,
|
||||
checkAzureVariables,
|
||||
checkWebSearchConfig,
|
||||
};
|
||||
@@ -11,15 +11,12 @@ jest.mock('@librechat/data-schemas', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { handleRateLimits } from './limits';
|
||||
import { checkWebSearchConfig } from './checks';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { extractVariableName as extract } from 'librechat-data-provider';
|
||||
|
||||
const extractVariableName = extract as jest.MockedFunction<typeof extract>;
|
||||
const { checkWebSearchConfig } = require('./checks');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { extractVariableName } = require('librechat-data-provider');
|
||||
|
||||
describe('checkWebSearchConfig', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
@@ -181,8 +178,6 @@ describe('checkWebSearchConfig', () => {
|
||||
anotherKey: '${SOME_VAR}',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/** @ts-expect-error */
|
||||
checkWebSearchConfig(config);
|
||||
|
||||
expect(extractVariableName).not.toHaveBeenCalled();
|
||||
@@ -205,154 +200,3 @@ describe('checkWebSearchConfig', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRateLimits', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original environment
|
||||
originalEnv = process.env;
|
||||
|
||||
// Reset process.env
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should correctly set FILE_UPLOAD environment variables based on rate limits', () => {
|
||||
const rateLimits = {
|
||||
fileUploads: {
|
||||
ipMax: 100,
|
||||
ipWindowInMinutes: 60,
|
||||
userMax: 50,
|
||||
userWindowInMinutes: 30,
|
||||
},
|
||||
};
|
||||
|
||||
handleRateLimits(rateLimits);
|
||||
|
||||
// Verify that process.env has been updated according to the rate limits config
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100');
|
||||
expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60');
|
||||
expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50');
|
||||
expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30');
|
||||
});
|
||||
|
||||
it('should correctly set IMPORT environment variables based on rate limits', () => {
|
||||
const rateLimits = {
|
||||
conversationsImport: {
|
||||
ipMax: 150,
|
||||
ipWindowInMinutes: 60,
|
||||
userMax: 50,
|
||||
userWindowInMinutes: 30,
|
||||
},
|
||||
};
|
||||
|
||||
handleRateLimits(rateLimits);
|
||||
|
||||
// Verify that process.env has been updated according to the rate limits config
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual('150');
|
||||
expect(process.env.IMPORT_IP_WINDOW).toEqual('60');
|
||||
expect(process.env.IMPORT_USER_MAX).toEqual('50');
|
||||
expect(process.env.IMPORT_USER_WINDOW).toEqual('30');
|
||||
});
|
||||
|
||||
it('should not modify FILE_UPLOAD environment variables without rate limits', () => {
|
||||
// Setup initial environment variables
|
||||
process.env.FILE_UPLOAD_IP_MAX = '10';
|
||||
process.env.FILE_UPLOAD_IP_WINDOW = '15';
|
||||
process.env.FILE_UPLOAD_USER_MAX = '5';
|
||||
process.env.FILE_UPLOAD_USER_WINDOW = '20';
|
||||
|
||||
const initialEnv = { ...process.env };
|
||||
|
||||
handleRateLimits({});
|
||||
|
||||
// Expect environment variables to remain unchanged
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX);
|
||||
expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual(initialEnv.FILE_UPLOAD_IP_WINDOW);
|
||||
expect(process.env.FILE_UPLOAD_USER_MAX).toEqual(initialEnv.FILE_UPLOAD_USER_MAX);
|
||||
expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW);
|
||||
});
|
||||
|
||||
it('should not modify IMPORT environment variables without rate limits', () => {
|
||||
// Setup initial environment variables
|
||||
process.env.IMPORT_IP_MAX = '10';
|
||||
process.env.IMPORT_IP_WINDOW = '15';
|
||||
process.env.IMPORT_USER_MAX = '5';
|
||||
process.env.IMPORT_USER_WINDOW = '20';
|
||||
|
||||
const initialEnv = { ...process.env };
|
||||
|
||||
handleRateLimits({});
|
||||
|
||||
// Expect environment variables to remain unchanged
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX);
|
||||
expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW);
|
||||
expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX);
|
||||
expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW);
|
||||
});
|
||||
|
||||
it('should handle undefined rateLimits parameter', () => {
|
||||
// Setup initial environment variables
|
||||
process.env.FILE_UPLOAD_IP_MAX = 'initial';
|
||||
process.env.IMPORT_IP_MAX = 'initial';
|
||||
|
||||
handleRateLimits(undefined);
|
||||
|
||||
// Should not modify any environment variables
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initial');
|
||||
expect(process.env.IMPORT_IP_MAX).toEqual('initial');
|
||||
});
|
||||
|
||||
it('should handle partial rate limit configurations', () => {
|
||||
const rateLimits = {
|
||||
fileUploads: {
|
||||
ipMax: 200,
|
||||
// Only setting ipMax, other properties undefined
|
||||
},
|
||||
};
|
||||
|
||||
handleRateLimits(rateLimits);
|
||||
|
||||
expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('200');
|
||||
// Other FILE_UPLOAD env vars should not be set
|
||||
expect(process.env.FILE_UPLOAD_IP_WINDOW).toBeUndefined();
|
||||
expect(process.env.FILE_UPLOAD_USER_MAX).toBeUndefined();
|
||||
expect(process.env.FILE_UPLOAD_USER_WINDOW).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly set TTS and STT environment variables based on rate limits', () => {
|
||||
const rateLimits = {
|
||||
tts: {
|
||||
ipMax: 75,
|
||||
ipWindowInMinutes: 45,
|
||||
userMax: 25,
|
||||
userWindowInMinutes: 15,
|
||||
},
|
||||
stt: {
|
||||
ipMax: 80,
|
||||
ipWindowInMinutes: 50,
|
||||
userMax: 30,
|
||||
userWindowInMinutes: 20,
|
||||
},
|
||||
};
|
||||
|
||||
handleRateLimits(rateLimits);
|
||||
|
||||
// Verify TTS environment variables
|
||||
expect(process.env.TTS_IP_MAX).toEqual('75');
|
||||
expect(process.env.TTS_IP_WINDOW).toEqual('45');
|
||||
expect(process.env.TTS_USER_MAX).toEqual('25');
|
||||
expect(process.env.TTS_USER_WINDOW).toEqual('15');
|
||||
|
||||
// Verify STT environment variables
|
||||
expect(process.env.STT_IP_MAX).toEqual('80');
|
||||
expect(process.env.STT_IP_WINDOW).toEqual('50');
|
||||
expect(process.env.STT_USER_MAX).toEqual('30');
|
||||
expect(process.env.STT_USER_WINDOW).toEqual('20');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,22 @@
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types';
|
||||
import { azureAssistantsDefaults, assistantsConfigSetup } from './assistants';
|
||||
import { agentsConfigSetup } from './agents';
|
||||
import { azureConfigSetup } from './azure';
|
||||
const { agentsConfigSetup } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./assistants');
|
||||
const { azureConfigSetup } = require('./azureOpenAI');
|
||||
const { checkAzureVariables } = require('./checks');
|
||||
|
||||
/**
|
||||
* Loads custom config endpoints
|
||||
* @param [config]
|
||||
* @param [agentsDefaults]
|
||||
* @param {TCustomConfig} [config]
|
||||
* @param {TCustomConfig['endpoints']['agents']} [agentsDefaults]
|
||||
*/
|
||||
export const loadEndpoints = (
|
||||
config: Partial<TCustomConfig>,
|
||||
agentsDefaults?: Partial<TAgentsEndpoint>,
|
||||
) => {
|
||||
const loadedEndpoints: AppConfig['endpoints'] = {};
|
||||
const loadEndpoints = (config, agentsDefaults) => {
|
||||
/** @type {AppConfig['endpoints']} */
|
||||
const loadedEndpoints = {};
|
||||
const endpoints = config?.endpoints;
|
||||
|
||||
if (endpoints?.[EModelEndpoint.azureOpenAI]) {
|
||||
loadedEndpoints[EModelEndpoint.azureOpenAI] = azureConfigSetup(config);
|
||||
checkAzureVariables();
|
||||
}
|
||||
|
||||
if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
|
||||
@@ -52,9 +50,8 @@ export const loadEndpoints = (
|
||||
];
|
||||
|
||||
endpointKeys.forEach((key) => {
|
||||
const currentKey = key as keyof typeof endpoints;
|
||||
if (endpoints?.[currentKey]) {
|
||||
loadedEndpoints[currentKey] = endpoints[currentKey];
|
||||
if (endpoints?.[key]) {
|
||||
loadedEndpoints[key] = endpoints[key];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,3 +61,7 @@ export const loadEndpoints = (
|
||||
|
||||
return loadedEndpoints;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
loadEndpoints,
|
||||
};
|
||||
75
api/server/services/start/modelSpecs.js
Normal file
75
api/server/services/start/modelSpecs.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { normalizeEndpointName } = require('@librechat/api');
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Sets up Model Specs from the config (`librechat.yaml`) file.
|
||||
* @param {TCustomConfig['endpoints']} [endpoints] - The loaded custom configuration for endpoints.
|
||||
* @param {TCustomConfig['modelSpecs'] | undefined} [modelSpecs] - The loaded custom configuration for model specs.
|
||||
* @param {TCustomConfig['interface'] | undefined} [interfaceConfig] - The loaded interface configuration.
|
||||
* @returns {TCustomConfig['modelSpecs'] | undefined} The processed model specs, if any.
|
||||
*/
|
||||
function processModelSpecs(endpoints, _modelSpecs, interfaceConfig) {
|
||||
if (!_modelSpecs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @type {TCustomConfig['modelSpecs']['list']} */
|
||||
const modelSpecs = [];
|
||||
/** @type {TCustomConfig['modelSpecs']['list']} */
|
||||
const list = _modelSpecs.list;
|
||||
|
||||
const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? [];
|
||||
|
||||
if (interfaceConfig.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) {
|
||||
logger.warn(
|
||||
`To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration.
|
||||
|
||||
Example:
|
||||
\`\`\`yaml
|
||||
interface:
|
||||
modelSelect: true
|
||||
\`\`\`
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const spec of list) {
|
||||
if (EModelEndpoint[spec.preset.endpoint] && spec.preset.endpoint !== EModelEndpoint.custom) {
|
||||
modelSpecs.push(spec);
|
||||
continue;
|
||||
} else if (spec.preset.endpoint === EModelEndpoint.custom) {
|
||||
logger.warn(
|
||||
`Model Spec with endpoint "${spec.preset.endpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedName = normalizeEndpointName(spec.preset.endpoint);
|
||||
const endpoint = customEndpoints.find(
|
||||
(customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name),
|
||||
);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.warn(`Model spec with endpoint "${spec.preset.endpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user.
|
||||
|
||||
For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`);
|
||||
continue;
|
||||
}
|
||||
|
||||
modelSpecs.push({
|
||||
...spec,
|
||||
preset: {
|
||||
...spec.preset,
|
||||
endpoint: normalizedName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
..._modelSpecs,
|
||||
list: modelSpecs,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { processModelSpecs };
|
||||
44
api/server/services/start/turnstile.js
Normal file
44
api/server/services/start/turnstile.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Loads and maps the Cloudflare Turnstile configuration.
|
||||
*
|
||||
* Expected config structure:
|
||||
*
|
||||
* turnstile:
|
||||
* siteKey: "your-site-key-here"
|
||||
* options:
|
||||
* language: "auto" // "auto" or an ISO 639-1 language code (e.g. en)
|
||||
* size: "normal" // Options: "normal", "compact", "flexible", or "invisible"
|
||||
*
|
||||
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
|
||||
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
|
||||
* @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration.
|
||||
*/
|
||||
function loadTurnstileConfig(config, configDefaults) {
|
||||
const { turnstile: customTurnstile = {} } = config ?? {};
|
||||
const { turnstile: defaults = {} } = configDefaults;
|
||||
|
||||
/** @type {TCustomConfig['turnstile']} */
|
||||
const loadedTurnstile = removeNullishValues({
|
||||
siteKey: customTurnstile.siteKey ?? defaults.siteKey,
|
||||
options: customTurnstile.options ?? defaults.options,
|
||||
});
|
||||
|
||||
const enabled = Boolean(loadedTurnstile.siteKey);
|
||||
|
||||
if (enabled) {
|
||||
logger.info(
|
||||
'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2),
|
||||
);
|
||||
} else {
|
||||
logger.info('Turnstile is DISABLED (no siteKey provided).');
|
||||
}
|
||||
|
||||
return loadedTurnstile;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadTurnstileConfig,
|
||||
};
|
||||
@@ -10,15 +10,6 @@ const importConversations = async (job) => {
|
||||
const { filepath, requestUserId } = job;
|
||||
try {
|
||||
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
||||
|
||||
/* error if file is too large */
|
||||
const fileInfo = await fs.stat(filepath);
|
||||
if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) {
|
||||
throw new Error(
|
||||
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileData = await fs.readFile(filepath, 'utf8');
|
||||
const jsonData = JSON.parse(fileData);
|
||||
const importer = getImporter(jsonData);
|
||||
@@ -26,7 +17,6 @@ const importConversations = async (job) => {
|
||||
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
|
||||
} catch (error) {
|
||||
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
|
||||
throw error; // throw error all the way up so request does not return success
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(filepath);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const undici = require('undici');
|
||||
const { get } = require('lodash');
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const client = require('openid-client');
|
||||
@@ -330,12 +329,6 @@ async function setupOpenId() {
|
||||
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
||||
});
|
||||
|
||||
// Set of env variables that specify how to set if a user is an admin
|
||||
// If not set, all users will be treated as regular users
|
||||
const adminRole = process.env.OPENID_ADMIN_ROLE;
|
||||
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||
|
||||
const openidLogin = new CustomOpenIDStrategy(
|
||||
{
|
||||
config: openidConfig,
|
||||
@@ -357,18 +350,16 @@ async function setupOpenId() {
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
||||
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
findUser,
|
||||
email: email,
|
||||
email: claims.email,
|
||||
openidId: claims.sub,
|
||||
idOnTheSource: claims.oid,
|
||||
strategyName: 'openidStrategy',
|
||||
@@ -395,19 +386,20 @@ async function setupOpenId() {
|
||||
} else if (requiredRoleTokenKind === 'id') {
|
||||
decodedToken = jwtDecode(tokenset.id_token);
|
||||
}
|
||||
const pathParts = requiredRoleParameterPath.split('.');
|
||||
let found = true;
|
||||
let roles = pathParts.reduce((o, key) => {
|
||||
if (o === null || o === undefined || !(key in o)) {
|
||||
found = false;
|
||||
return [];
|
||||
}
|
||||
return o[key];
|
||||
}, decodedToken);
|
||||
|
||||
let roles = get(decodedToken, requiredRoleParameterPath);
|
||||
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
|
||||
if (!found) {
|
||||
logger.error(
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`,
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||
);
|
||||
const rolesList =
|
||||
requiredRoles.length === 1
|
||||
? `"${requiredRoles[0]}"`
|
||||
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
||||
return done(null, false, {
|
||||
message: `You must have ${rolesList} role to log in.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!requiredRoles.some((role) => roles.includes(role))) {
|
||||
@@ -435,7 +427,7 @@ async function setupOpenId() {
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: email || '',
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
@@ -449,56 +441,12 @@ async function setupOpenId() {
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
if (email && email !== user.email) {
|
||||
user.email = email;
|
||||
if (userinfo.email && userinfo.email !== user.email) {
|
||||
user.email = userinfo.email;
|
||||
user.emailVerified = userinfo.email_verified || false;
|
||||
}
|
||||
}
|
||||
|
||||
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
|
||||
let adminRoleObject;
|
||||
switch (adminRoleTokenKind) {
|
||||
case 'access':
|
||||
adminRoleObject = jwtDecode(tokenset.access_token);
|
||||
break;
|
||||
case 'id':
|
||||
adminRoleObject = jwtDecode(tokenset.id_token);
|
||||
break;
|
||||
case 'userinfo':
|
||||
adminRoleObject = userinfo;
|
||||
break;
|
||||
default:
|
||||
logger.error(
|
||||
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
|
||||
);
|
||||
return done(new Error('Invalid admin role token kind'));
|
||||
}
|
||||
|
||||
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
|
||||
|
||||
// Accept 3 types of values for the object extracted from adminRoleParameterPath:
|
||||
// 1. A boolean value indicating if the user is an admin
|
||||
// 2. A string with a single role name
|
||||
// 3. An array of role names
|
||||
|
||||
if (
|
||||
adminRoles &&
|
||||
(adminRoles === true ||
|
||||
adminRoles === adminRole ||
|
||||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
|
||||
) {
|
||||
user.role = 'ADMIN';
|
||||
logger.info(
|
||||
`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`,
|
||||
);
|
||||
} else if (user.role === 'ADMIN') {
|
||||
user.role = 'USER';
|
||||
logger.info(
|
||||
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
@@ -125,9 +125,6 @@ describe('setupOpenId', () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions';
|
||||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||||
delete process.env.OPENID_USERNAME_CLAIM;
|
||||
delete process.env.OPENID_NAME_CLAIM;
|
||||
delete process.env.PROXY;
|
||||
@@ -136,7 +133,6 @@ describe('setupOpenId', () => {
|
||||
// Default jwtDecode mock returns a token that includes the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
permissions: ['admin'],
|
||||
});
|
||||
|
||||
// By default, assume that no user is found, so createUser will be called
|
||||
@@ -445,475 +441,4 @@ describe('setupOpenId', () => {
|
||||
expect(callOptions.usePKCE).toBe(false);
|
||||
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the user role is set to "ADMIN"
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => {
|
||||
// Arrange – simulate a token without the admin permission
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the user role is not defined
|
||||
expect(user.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should demote existing admin user when admin role is removed from token', async () => {
|
||||
// Arrange – simulate an existing user who is currently an admin
|
||||
const existingAdminUser = {
|
||||
_id: 'existingAdminId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'adminuser',
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingAdminUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Token without admin permission
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
permissions: ['not-admin'],
|
||||
});
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the user was demoted
|
||||
expect(user.role).toBe('USER');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
existingAdminUser._id,
|
||||
expect.objectContaining({
|
||||
role: 'USER',
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('demoted from admin - role no longer present in token'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT demote admin user when admin role env vars are not configured', async () => {
|
||||
// Arrange – remove admin role env vars
|
||||
delete process.env.OPENID_ADMIN_ROLE;
|
||||
delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||
delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
// Simulate an existing admin user
|
||||
const existingAdminUser = {
|
||||
_id: 'existingAdminId',
|
||||
provider: 'openid',
|
||||
email: tokenset.claims().email,
|
||||
openidId: tokenset.claims().sub,
|
||||
username: 'adminuser',
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
findUser.mockImplementation(async (query) => {
|
||||
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||
return existingAdminUser;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
});
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
// Assert – verify that the admin user was NOT demoted
|
||||
expect(user.role).toBe('ADMIN');
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
existingAdminUser._id,
|
||||
expect.objectContaining({
|
||||
role: 'ADMIN',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('lodash get - nested path extraction', () => {
|
||||
it('should extract roles from deeply nested token path', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
resource_access: {
|
||||
'my-client': {
|
||||
roles: ['app-user', 'viewer'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user).toBeTruthy();
|
||||
expect(user.email).toBe(tokenset.claims().email);
|
||||
});
|
||||
|
||||
it('should extract roles from three-level nested path', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'editor';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
data: {
|
||||
access: {
|
||||
permissions: {
|
||||
roles: ['editor', 'reader'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should log error and reject login when required role path does not exist in token', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
resource_access: {
|
||||
'my-client': {
|
||||
roles: ['app-user'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user, details } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Key 'resource_access.nonexistent.roles' not found or invalid type in id token!",
|
||||
),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
expect(details.message).toContain('role to log in');
|
||||
});
|
||||
|
||||
it('should handle missing intermediate nested path gracefully', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
org: {
|
||||
other: 'value',
|
||||
},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should extract admin role from nested path in access token', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles';
|
||||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
|
||||
|
||||
jwtDecode.mockImplementation((token) => {
|
||||
if (token === 'fake_access_token') {
|
||||
return {
|
||||
realm_access: {
|
||||
roles: ['admin', 'user'],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
roles: ['requiredRole'],
|
||||
};
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should extract admin role from nested path in userinfo', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions';
|
||||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo';
|
||||
|
||||
const userinfoWithNestedGroups = {
|
||||
...tokenset.claims(),
|
||||
organization: {
|
||||
permissions: ['admin', 'write'],
|
||||
},
|
||||
};
|
||||
|
||||
require('openid-client').fetchUserInfo.mockResolvedValue({
|
||||
organization: {
|
||||
permissions: ['admin', 'write'],
|
||||
},
|
||||
});
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate({
|
||||
...tokenset,
|
||||
claims: () => userinfoWithNestedGroups,
|
||||
});
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should handle boolean admin role value', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
is_admin: true,
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should handle string admin role value matching exactly', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
role: 'super-admin',
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should not set admin role when string value does not match', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
role: 'regular-user',
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle array admin role value', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
app_roles: ['user', 'site-admin', 'moderator'],
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('should not set admin when role is not in array', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
app_roles: ['user', 'moderator'],
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle nested path with special characters in keys', async () => {
|
||||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
resource_access: {
|
||||
'my-app-123': {
|
||||
roles: ['app-user'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(user).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle empty object at nested path', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
access: {},
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null value at intermediate path', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
data: null,
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject login with invalid admin role token kind', async () => {
|
||||
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles';
|
||||
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid';
|
||||
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole', 'admin'],
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind');
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject login when roles path returns invalid type (object)', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: { admin: true, user: false },
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user, details } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Key 'roles' not found or invalid type in id token!"),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
expect(details.message).toContain('role to log in');
|
||||
});
|
||||
|
||||
it('should reject login when roles path returns invalid type (number)', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount';
|
||||
|
||||
jwtDecode.mockReturnValue({
|
||||
roleCount: 5,
|
||||
});
|
||||
|
||||
await setupOpenId();
|
||||
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||
|
||||
const { user } = await validate(tokenset);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"),
|
||||
);
|
||||
expect(user).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,7 +1054,7 @@
|
||||
|
||||
/**
|
||||
* @exports TWebSearchKeys
|
||||
* @typedef {import('@librechat/data-schemas').TWebSearchKeys} TWebSearchKeys
|
||||
* @typedef {import('librechat-data-provider').TWebSearchKeys} TWebSearchKeys
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
@@ -1103,13 +1103,13 @@
|
||||
|
||||
/**
|
||||
* @exports AppConfig
|
||||
* @typedef {import('@librechat/data-schemas').AppConfig} AppConfig
|
||||
* @typedef {import('@librechat/api').AppConfig} AppConfig
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports JsonSchemaType
|
||||
* @typedef {import('@librechat/data-schemas').JsonSchemaType} JsonSchemaType
|
||||
* @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
@@ -1371,7 +1371,12 @@
|
||||
|
||||
/**
|
||||
* @exports FunctionTool
|
||||
* @typedef {import('@librechat/data-schemas').FunctionTool} FunctionTool
|
||||
* @typedef {Object} FunctionTool
|
||||
* @property {'function'} type - The type of tool, 'function'.
|
||||
* @property {Object} function - The function definition.
|
||||
* @property {string} function.description - A description of what the function does.
|
||||
* @property {string} function.name - The name of the function to be called.
|
||||
* @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object.
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
|
||||
@@ -186,19 +186,6 @@ describe('getModelMaxTokens', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for gpt-5-pro matches', () => {
|
||||
expect(getModelMaxTokens('gpt-5-pro')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro']);
|
||||
expect(getModelMaxTokens('gpt-5-pro-preview')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
|
||||
);
|
||||
expect(getModelMaxTokens('openai/gpt-5-pro')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
|
||||
);
|
||||
expect(getModelMaxTokens('gpt-5-pro-2025-01-30')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Anthropic models', () => {
|
||||
const models = [
|
||||
'claude-2.1',
|
||||
@@ -409,80 +396,15 @@ describe('getModelMaxTokens', () => {
|
||||
});
|
||||
|
||||
test('should return correct tokens for GPT-OSS models', () => {
|
||||
const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss'];
|
||||
[
|
||||
'gpt-oss:20b',
|
||||
'gpt-oss-20b',
|
||||
'gpt-oss-120b',
|
||||
'openai/gpt-oss-20b',
|
||||
'openai/gpt-oss-120b',
|
||||
'openai/gpt-oss:120b',
|
||||
].forEach((name) => {
|
||||
const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b'];
|
||||
['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => {
|
||||
expect(getModelMaxTokens(name)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return correct tokens for GLM models', () => {
|
||||
expect(getModelMaxTokens('glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']);
|
||||
expect(getModelMaxTokens('glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']);
|
||||
expect(getModelMaxTokens('glm-4.5-air')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'],
|
||||
);
|
||||
expect(getModelMaxTokens('glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']);
|
||||
expect(getModelMaxTokens('glm-4-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4-32b']);
|
||||
expect(getModelMaxTokens('glm-4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4']);
|
||||
expect(getModelMaxTokens('glm4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm4']);
|
||||
});
|
||||
|
||||
test('should return correct tokens for GLM models with provider prefixes', () => {
|
||||
expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'],
|
||||
);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'],
|
||||
);
|
||||
expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'],
|
||||
);
|
||||
|
||||
expect(getModelMaxTokens('zai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']);
|
||||
expect(getModelMaxTokens('zai/glm-4.5-air')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai/glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']);
|
||||
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.6'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for GLM models with suffixes', () => {
|
||||
expect(getModelMaxTokens('glm-4.6-fp8')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.6'],
|
||||
);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct max output tokens for GPT-5 models', () => {
|
||||
const { getModelMaxOutputTokens } = require('@librechat/api');
|
||||
['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => {
|
||||
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
|
||||
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
|
||||
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
|
||||
maxOutputTokensMap[EModelEndpoint.openAI][model],
|
||||
@@ -595,13 +517,6 @@ describe('matchModelName', () => {
|
||||
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
|
||||
});
|
||||
|
||||
it('should return the closest matching key for gpt-5-pro matches', () => {
|
||||
expect(matchModelName('openai/gpt-5-pro')).toBe('gpt-5-pro');
|
||||
expect(matchModelName('gpt-5-pro-preview')).toBe('gpt-5-pro');
|
||||
expect(matchModelName('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
|
||||
expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
|
||||
});
|
||||
|
||||
// Tests for Google models
|
||||
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
|
||||
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
|
||||
@@ -852,49 +767,6 @@ describe('Claude Model Tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct context length for Claude Haiku 4.5', () => {
|
||||
expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe(
|
||||
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
|
||||
);
|
||||
expect(getModelMaxTokens('claude-haiku-4-5')).toBe(
|
||||
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Claude Haiku 4.5 model name variations', () => {
|
||||
const modelVariations = [
|
||||
'claude-haiku-4-5',
|
||||
'claude-haiku-4-5-20250420',
|
||||
'claude-haiku-4-5-latest',
|
||||
'anthropic/claude-haiku-4-5',
|
||||
'claude-haiku-4-5/anthropic',
|
||||
'claude-haiku-4-5-preview',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
|
||||
expect(modelKey).toBe('claude-haiku-4-5');
|
||||
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
|
||||
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should match model names correctly for Claude Haiku 4.5', () => {
|
||||
const modelVariations = [
|
||||
'claude-haiku-4-5',
|
||||
'claude-haiku-4-5-20250420',
|
||||
'claude-haiku-4-5-latest',
|
||||
'anthropic/claude-haiku-4-5',
|
||||
'claude-haiku-4-5/anthropic',
|
||||
'claude-haiku-4-5-preview',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-haiku-4-5');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4',
|
||||
@@ -986,206 +858,3 @@ describe('Kimi Model Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen3 Model Tests', () => {
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for Qwen3 base pattern', () => {
|
||||
expect(getModelMaxTokens('qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
|
||||
});
|
||||
|
||||
test('should return correct tokens for qwen3-4b (falls back to qwen3)', () => {
|
||||
expect(getModelMaxTokens('qwen3-4b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Qwen3 base models', () => {
|
||||
expect(getModelMaxTokens('qwen3-8b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-8b']);
|
||||
expect(getModelMaxTokens('qwen3-14b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-14b']);
|
||||
expect(getModelMaxTokens('qwen3-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-32b']);
|
||||
expect(getModelMaxTokens('qwen3-235b-a22b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-235b-a22b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Qwen3 VL (Vision-Language) models', () => {
|
||||
expect(getModelMaxTokens('qwen3-vl-8b-thinking')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-thinking'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-vl-8b-instruct')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-vl-30b-a3b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-30b-a3b'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-vl-235b-a22b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-235b-a22b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Qwen3 specialized models', () => {
|
||||
expect(getModelMaxTokens('qwen3-max')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-max']);
|
||||
expect(getModelMaxTokens('qwen3-coder')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-coder-30b-a3b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-30b-a3b'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-coder-plus')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-plus'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-coder-flash')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-flash'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-next-80b-a3b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-next-80b-a3b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle Qwen3 models with provider prefixes', () => {
|
||||
expect(getModelMaxTokens('alibaba/qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
|
||||
expect(getModelMaxTokens('alibaba/qwen3-4b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen/qwen3-8b')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
|
||||
);
|
||||
expect(getModelMaxTokens('openrouter/qwen3-max')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
|
||||
);
|
||||
expect(getModelMaxTokens('alibaba/qwen3-vl-8b-instruct')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen/qwen3-coder')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle Qwen3 models with suffixes', () => {
|
||||
expect(getModelMaxTokens('qwen3-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
|
||||
expect(getModelMaxTokens('qwen3-4b-preview')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-8b-latest')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
|
||||
);
|
||||
expect(getModelMaxTokens('qwen3-max-2024')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchModelName', () => {
|
||||
test('should match exact Qwen3 model names', () => {
|
||||
expect(matchModelName('qwen3')).toBe('qwen3');
|
||||
expect(matchModelName('qwen3-4b')).toBe('qwen3');
|
||||
expect(matchModelName('qwen3-8b')).toBe('qwen3-8b');
|
||||
expect(matchModelName('qwen3-vl-8b-thinking')).toBe('qwen3-vl-8b-thinking');
|
||||
expect(matchModelName('qwen3-max')).toBe('qwen3-max');
|
||||
expect(matchModelName('qwen3-coder')).toBe('qwen3-coder');
|
||||
});
|
||||
|
||||
test('should match Qwen3 model variations with provider prefixes', () => {
|
||||
expect(matchModelName('alibaba/qwen3')).toBe('qwen3');
|
||||
expect(matchModelName('alibaba/qwen3-4b')).toBe('qwen3');
|
||||
expect(matchModelName('qwen/qwen3-8b')).toBe('qwen3-8b');
|
||||
expect(matchModelName('openrouter/qwen3-max')).toBe('qwen3-max');
|
||||
expect(matchModelName('alibaba/qwen3-vl-8b-instruct')).toBe('qwen3-vl-8b-instruct');
|
||||
expect(matchModelName('qwen/qwen3-coder')).toBe('qwen3-coder');
|
||||
});
|
||||
|
||||
test('should match Qwen3 model variations with suffixes', () => {
|
||||
expect(matchModelName('qwen3-preview')).toBe('qwen3');
|
||||
expect(matchModelName('qwen3-4b-preview')).toBe('qwen3');
|
||||
expect(matchModelName('qwen3-8b-latest')).toBe('qwen3-8b');
|
||||
expect(matchModelName('qwen3-max-2024')).toBe('qwen3-max');
|
||||
expect(matchModelName('qwen3-coder-v1')).toBe('qwen3-coder');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GLM Model Tests (Zhipu AI)', () => {
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for GLM models', () => {
|
||||
expect(getModelMaxTokens('glm-4.6')).toBe(200000);
|
||||
expect(getModelMaxTokens('glm-4.5v')).toBe(66000);
|
||||
expect(getModelMaxTokens('glm-4.5-air')).toBe(131000);
|
||||
expect(getModelMaxTokens('glm-4.5')).toBe(131000);
|
||||
expect(getModelMaxTokens('glm-4-32b')).toBe(128000);
|
||||
expect(getModelMaxTokens('glm-4')).toBe(128000);
|
||||
expect(getModelMaxTokens('glm4')).toBe(128000);
|
||||
});
|
||||
|
||||
test('should handle partial matches for GLM models with provider prefixes', () => {
|
||||
expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(200000);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(131000);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe(131000);
|
||||
expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe(66000);
|
||||
expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe(128000);
|
||||
|
||||
expect(getModelMaxTokens('zai/glm-4.6')).toBe(200000);
|
||||
expect(getModelMaxTokens('zai/glm-4.5')).toBe(131000);
|
||||
expect(getModelMaxTokens('zai/glm-4.5-air')).toBe(131000);
|
||||
expect(getModelMaxTokens('zai/glm-4.5v')).toBe(66000);
|
||||
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe(200000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe(131000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe(131000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe(66000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe(128000);
|
||||
});
|
||||
|
||||
test('should handle GLM model variations with suffixes', () => {
|
||||
expect(getModelMaxTokens('glm-4.6-fp8')).toBe(200000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe(200000);
|
||||
expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe(131000);
|
||||
});
|
||||
|
||||
test('should prioritize more specific GLM patterns', () => {
|
||||
expect(getModelMaxTokens('glm-4.5-air-custom')).toBe(131000);
|
||||
expect(getModelMaxTokens('glm-4.5-custom')).toBe(131000);
|
||||
expect(getModelMaxTokens('glm-4.5v-custom')).toBe(66000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchModelName', () => {
|
||||
test('should match exact GLM model names', () => {
|
||||
expect(matchModelName('glm-4.6')).toBe('glm-4.6');
|
||||
expect(matchModelName('glm-4.5v')).toBe('glm-4.5v');
|
||||
expect(matchModelName('glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(matchModelName('glm-4.5')).toBe('glm-4.5');
|
||||
expect(matchModelName('glm-4-32b')).toBe('glm-4-32b');
|
||||
expect(matchModelName('glm-4')).toBe('glm-4');
|
||||
expect(matchModelName('glm4')).toBe('glm4');
|
||||
});
|
||||
|
||||
test('should match GLM model variations with provider prefixes', () => {
|
||||
expect(matchModelName('z-ai/glm-4.6')).toBe('glm-4.6');
|
||||
expect(matchModelName('z-ai/glm-4.5')).toBe('glm-4.5');
|
||||
expect(matchModelName('z-ai/glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(matchModelName('z-ai/glm-4.5v')).toBe('glm-4.5v');
|
||||
expect(matchModelName('z-ai/glm-4-32b')).toBe('glm-4-32b');
|
||||
|
||||
expect(matchModelName('zai/glm-4.6')).toBe('glm-4.6');
|
||||
expect(matchModelName('zai/glm-4.5')).toBe('glm-4.5');
|
||||
expect(matchModelName('zai/glm-4.5-air')).toBe('glm-4.5-air');
|
||||
expect(matchModelName('zai/glm-4.5v')).toBe('glm-4.5v');
|
||||
|
||||
expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6');
|
||||
expect(matchModelName('zai-org/GLM-4.5')).toBe('glm-4.5');
|
||||
expect(matchModelName('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air');
|
||||
expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v');
|
||||
expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b');
|
||||
});
|
||||
|
||||
test('should match GLM model variations with suffixes', () => {
|
||||
expect(matchModelName('glm-4.6-fp8')).toBe('glm-4.6');
|
||||
expect(matchModelName('zai-org/GLM-4.6-FP8')).toBe('glm-4.6');
|
||||
expect(matchModelName('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air');
|
||||
});
|
||||
|
||||
test('should handle case-insensitive matching for GLM models', () => {
|
||||
expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6');
|
||||
expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v');
|
||||
expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user