Compare commits
6 Commits
feat/openi
...
feat/Custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9486599268 | ||
|
|
f439f1a80a | ||
|
|
59a232812d | ||
|
|
edf23eb2ae | ||
|
|
262e6aa4c9 | ||
|
|
7dfb386f5a |
@@ -438,10 +438,6 @@ OPENID_USERNAME_CLAIM=
|
||||
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
||||
OPENID_NAME_CLAIM=
|
||||
|
||||
OPENID_CUSTOM_DATA=
|
||||
OPENID_PROVIDER=
|
||||
OPENID_ADMIN_ROLE=
|
||||
|
||||
OPENID_BUTTON_LABEL=
|
||||
OPENID_IMAGE_URL=
|
||||
# Set to true to automatically redirect to the OpenID provider when a user visits the login page
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
const { matchModelName } = require('../utils');
|
||||
const defaultRate = 6;
|
||||
|
||||
const customTokenOverrides = {};
|
||||
const customCacheOverrides = {};
|
||||
|
||||
/**
|
||||
* Allows overriding the default token multipliers.
|
||||
*
|
||||
* @param {Object} overrides - An object mapping model keys to their custom token multipliers.
|
||||
* @param {Object} overrides.<model> - An object containing custom multipliers for the model.
|
||||
* @param {number} overrides.<model>.prompt - The custom prompt multiplier for the model.
|
||||
* @param {number} overrides.<model>.completion - The custom completion multiplier for the model.
|
||||
*
|
||||
* @example
|
||||
* // Override the multipliers for "gpt-4o-mini" and "gpt-3.5":
|
||||
* setCustomTokenOverrides({
|
||||
* "gpt-4o-mini": { prompt: 0.2, completion: 0.5 },
|
||||
* "gpt-3.5": { prompt: 1.0, completion: 2.0 }
|
||||
* });
|
||||
*/
|
||||
const setCustomTokenOverrides = (overrides) => {
|
||||
Object.assign(customTokenOverrides, overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows overriding the default cache multipliers.
|
||||
* The override values should be nested under a key named "Cache".
|
||||
*
|
||||
* @param {Object} overrides - An object mapping model keys to their custom cache multipliers.
|
||||
* @param {Object} overrides.<model> - An object that must include a "Cache" property.
|
||||
* @param {Object} overrides.<model>.Cache - An object containing custom cache multipliers for the model.
|
||||
* @param {number} overrides.<model>.Cache.write - The custom cache write multiplier for the model.
|
||||
* @param {number} overrides.<model>.Cache.read - The custom cache read multiplier for the model.
|
||||
*
|
||||
* @example
|
||||
* // Override the cache multipliers for "gpt-4o-mini" and "gpt-3.5":
|
||||
* setCustomCacheOverrides({
|
||||
* "gpt-4o-mini": { cache: { write: 0.2, read: 0.5 } },
|
||||
* "gpt-3.5": { cache: { write: 1.0, read: 1.5 } }
|
||||
* });
|
||||
*/
|
||||
const setCustomCacheOverrides = (overrides) => {
|
||||
Object.assign(customCacheOverrides, overrides);
|
||||
};
|
||||
|
||||
/**
|
||||
* AWS Bedrock pricing
|
||||
* source: https://aws.amazon.com/bedrock/pricing/
|
||||
@@ -283,20 +326,23 @@ const getCacheMultiplier = ({ valueKey, cacheType, model, endpoint, endpointToke
|
||||
return endpointTokenConfig?.[model]?.[cacheType] ?? null;
|
||||
}
|
||||
|
||||
if (valueKey && cacheType) {
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
if (!valueKey && model) {
|
||||
valueKey = getValueKey(model, endpoint);
|
||||
}
|
||||
|
||||
if (!cacheType || !model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
valueKey = getValueKey(model, endpoint);
|
||||
if (!valueKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we got this far, and values[cacheType] is undefined somehow, return a rough average of default multipliers
|
||||
// Check for custom cache overrides under the "cache" property.
|
||||
if (
|
||||
customCacheOverrides[valueKey] &&
|
||||
customCacheOverrides[valueKey].cache &&
|
||||
customCacheOverrides[valueKey].cache[cacheType] != null
|
||||
) {
|
||||
return customCacheOverrides[valueKey].cache[cacheType];
|
||||
}
|
||||
|
||||
// Fallback to the default cacheTokenValues.
|
||||
return cacheTokenValues[valueKey]?.[cacheType] ?? null;
|
||||
};
|
||||
|
||||
@@ -307,4 +353,6 @@ module.exports = {
|
||||
getCacheMultiplier,
|
||||
defaultRate,
|
||||
cacheTokenValues,
|
||||
setCustomTokenOverrides,
|
||||
setCustomCacheOverrides,
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"@librechat/agents": "^2.4.317",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cohere-ai": "^7.9.1",
|
||||
@@ -93,7 +92,7 @@
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^4.96.2",
|
||||
"openai-chat-tokens": "^0.2.8",
|
||||
"openid-client": "^5.7.1",
|
||||
"openid-client": "^5.4.2",
|
||||
"passport": "^0.6.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-discord": "^0.1.4",
|
||||
|
||||
@@ -75,7 +75,6 @@ router.get('/', async function (req, res) {
|
||||
process.env.SHOW_BIRTHDAY_ICON === '',
|
||||
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
||||
interface: req.app.locals.interfaceConfig,
|
||||
turnstile: req.app.locals.turnstileConfig,
|
||||
modelSpecs: req.app.locals.modelSpecs,
|
||||
balance: req.app.locals.balance,
|
||||
sharedLinksEnabled,
|
||||
|
||||
@@ -12,7 +12,6 @@ const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
const { loadTurnstileConfig } = require('./start/turnstile');
|
||||
const { azureConfigSetup } = require('./start/azureOpenAI');
|
||||
const { processModelSpecs } = require('./start/modelSpecs');
|
||||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
@@ -22,8 +21,10 @@ const { initializeRoles } = require('~/models/Role');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const paths = require('~/config/paths');
|
||||
const { loadTokenRatesConfig } = require('./Config/loadTokenRatesConfig');
|
||||
|
||||
/**
|
||||
*
|
||||
* Loads custom config and initializes app-wide variables.
|
||||
* @function AppService
|
||||
* @param {Express.Application} app - The Express application object.
|
||||
@@ -33,6 +34,7 @@ const AppService = async (app) => {
|
||||
/** @type {TCustomConfig} */
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
const configDefaults = getConfigDefaults();
|
||||
loadTokenRatesConfig(config, configDefaults);
|
||||
|
||||
const ocr = loadOCRConfig(config.ocr);
|
||||
const filteredTools = config.filteredTools;
|
||||
@@ -74,7 +76,6 @@ const AppService = async (app) => {
|
||||
const socialLogins =
|
||||
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
||||
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
|
||||
const turnstileConfig = loadTurnstileConfig(config, configDefaults);
|
||||
|
||||
const defaultLocals = {
|
||||
ocr,
|
||||
@@ -86,7 +87,6 @@ const AppService = async (app) => {
|
||||
availableTools,
|
||||
imageOutputType,
|
||||
interfaceConfig,
|
||||
turnstileConfig,
|
||||
balance,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,12 +46,6 @@ jest.mock('./ToolService', () => ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('./start/turnstile', () => ({
|
||||
loadTurnstileConfig: jest.fn(() => ({
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const azureGroups = [
|
||||
{
|
||||
@@ -92,10 +86,6 @@ const azureGroups = [
|
||||
|
||||
describe('AppService', () => {
|
||||
let app;
|
||||
const mockedTurnstileConfig = {
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
app = { locals: {} };
|
||||
@@ -117,7 +107,6 @@ describe('AppService', () => {
|
||||
sidePanel: true,
|
||||
presets: true,
|
||||
}),
|
||||
turnstileConfig: mockedTurnstileConfig,
|
||||
modelSpecs: undefined,
|
||||
availableTools: {
|
||||
ExampleTool: {
|
||||
|
||||
71
api/server/services/Config/loadTokenRatesConfig.js
Normal file
71
api/server/services/Config/loadTokenRatesConfig.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const { logger } = require('~/config');
|
||||
const { setCustomTokenOverrides, setCustomCacheOverrides } = require('~/models/tx');
|
||||
|
||||
/**
|
||||
* Loads token rates from the user's configuration, merging with default token rates if available.
|
||||
*
|
||||
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
|
||||
* @param {TConfigDefaults} [configDefaults] - Optional default configuration values.
|
||||
* @returns {TCustomConfig['tokenRates']} - The final token rates configuration.
|
||||
*/
|
||||
function loadTokenRatesConfig(config, configDefaults) {
|
||||
const userTokenRates = removeNullishValues(config?.tokenRates ?? {});
|
||||
|
||||
if (!configDefaults?.tokenRates) {
|
||||
logger.info(`User tokenRates configuration:\n${JSON.stringify(userTokenRates, null, 2)}`);
|
||||
// Apply custom token rates even if there are no defaults
|
||||
applyCustomTokenRates(userTokenRates);
|
||||
return userTokenRates;
|
||||
}
|
||||
|
||||
/** @type {TCustomConfig['tokenRates']} */
|
||||
const defaultTokenRates = removeNullishValues(configDefaults.tokenRates);
|
||||
const merged = { ...defaultTokenRates, ...userTokenRates };
|
||||
|
||||
// Apply custom token rates configuration
|
||||
applyCustomTokenRates(merged);
|
||||
|
||||
logger.info(`Merged tokenRates configuration:\n${JSON.stringify(merged, null, 2)}`);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the token rates configuration to set up custom overrides for each model.
|
||||
*
|
||||
* The configuration is expected to be specified per model:
|
||||
*
|
||||
* For each model in the tokenRates configuration, this function will call the tx.js
|
||||
* override functions to apply the custom token and cache multipliers.
|
||||
*
|
||||
* @param {TModelTokenRates} tokenRates - The token rates configuration mapping models to token costs.
|
||||
*/
|
||||
function applyCustomTokenRates(tokenRates) {
|
||||
// Iterate over each model in the tokenRates configuration.
|
||||
Object.keys(tokenRates).forEach((model) => {
|
||||
const rate = tokenRates[model];
|
||||
// If token multipliers are provided, set custom token overrides.
|
||||
if (rate.prompt != null || rate.completion != null) {
|
||||
setCustomTokenOverrides({
|
||||
[model]: {
|
||||
prompt: rate.prompt,
|
||||
completion: rate.completion,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Check for cache overrides.
|
||||
const cacheOverrides = rate.cache;
|
||||
if (cacheOverrides && (cacheOverrides.write != null || cacheOverrides.read != null)) {
|
||||
setCustomCacheOverrides({
|
||||
[model]: {
|
||||
cache: {
|
||||
write: cacheOverrides.write,
|
||||
read: cacheOverrides.read,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { loadTokenRatesConfig };
|
||||
@@ -1,35 +0,0 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
logger.info('Turnstile configuration loaded:\n' + JSON.stringify(loadedTurnstile, null, 2));
|
||||
return loadedTurnstile;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadTurnstileConfig,
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
const fetch = require('node-fetch');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
// Microsoft SDK
|
||||
const { Client: MicrosoftGraphClient } = require('@microsoft/microsoft-graph-client');
|
||||
|
||||
/**
|
||||
* Base class for provider-specific data mappers.
|
||||
*/
|
||||
class BaseDataMapper {
|
||||
/**
|
||||
* Map custom OpenID data.
|
||||
* @param {string} accessToken - The access token to authenticate the request.
|
||||
* @param {string|Array<string>} customQuery - Either a full query string (if it contains operators)
|
||||
* or an array of fields to select.
|
||||
* @returns {Promise<Record<string, unknown>>} A promise that resolves to an object of custom fields.
|
||||
* @throws {Error} Throws an error if not implemented in the subclass.
|
||||
*/
|
||||
async mapCustomData(accessToken, customQuery) {
|
||||
throw new Error('mapCustomData() must be implemented by subclasses');
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally handle proxy settings for HTTP requests.
|
||||
* @returns {Object} Configuration object with proxy settings if PROXY is set.
|
||||
*/
|
||||
getProxyOptions() {
|
||||
if (process.env.PROXY) {
|
||||
const agent = new HttpsProxyAgent(process.env.PROXY);
|
||||
return { agent };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft-specific data mapper using the Microsoft Graph SDK.
|
||||
*/
|
||||
class MicrosoftDataMapper extends BaseDataMapper {
|
||||
/**
|
||||
* Initializes the MicrosoftGraphClient once for reuse.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
this.accessToken = null;
|
||||
|
||||
this.client = MicrosoftGraphClient.init({
|
||||
defaultVersion: 'beta',
|
||||
authProvider: (done) => {
|
||||
// The authProvider will be called for each request to get the token
|
||||
if (this.accessToken) {
|
||||
done(null, this.accessToken);
|
||||
} else {
|
||||
done(new Error('Access token is not set.'), null);
|
||||
}
|
||||
},
|
||||
fetch: fetch,
|
||||
...this.getProxyOptions(),
|
||||
});
|
||||
|
||||
// Bind methods to maintain context
|
||||
this.mapCustomData = this.mapCustomData.bind(this);
|
||||
this.cleanData = this.cleanData.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the access token for the client.
|
||||
* This method should be called before making any requests.
|
||||
*
|
||||
* @param {string} accessToken - The access token.
|
||||
*/
|
||||
setAccessToken(accessToken) {
|
||||
if (!accessToken || typeof accessToken !== 'string') {
|
||||
throw new Error('[MicrosoftDataMapper] Invalid access token provided.');
|
||||
}
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map custom OpenID data using the Microsoft Graph SDK.
|
||||
*
|
||||
* @param {string} accessToken - The access token to authenticate the request.
|
||||
* @param {string|Array<string>} customQuery - Fields to select from the Microsoft Graph API.
|
||||
* @returns {Promise<Record<string, unknown>>} A promise that resolves to an object of custom fields.
|
||||
*/
|
||||
async mapCustomData(accessToken, customQuery) {
|
||||
try {
|
||||
this.setAccessToken(accessToken);
|
||||
|
||||
if (!customQuery) {
|
||||
logger.warn('[MicrosoftDataMapper] No customQuery provided.');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convert customQuery to a comma-separated string if it's an array
|
||||
const fields = Array.isArray(customQuery) ? customQuery.join(',') : customQuery;
|
||||
|
||||
if (!fields) {
|
||||
logger.warn('[MicrosoftDataMapper] No fields specified in customQuery.');
|
||||
return {};
|
||||
}
|
||||
|
||||
const result = await this.client.api('/me').select(fields).get();
|
||||
|
||||
// Clean and return the data as a plain object
|
||||
return this.cleanData(result);
|
||||
} catch (error) {
|
||||
// Handle specific Microsoft Graph errors if needed
|
||||
logger.error(`[MicrosoftDataMapper] Error fetching user data: ${error.message}`, {
|
||||
stack: error.stack,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove all keys starting with @odata. from an object or array.
|
||||
*
|
||||
* @param {object|Array} obj - The object or array to clean.
|
||||
* @returns {object|Array} - The cleaned object or array.
|
||||
*/
|
||||
cleanData(obj) {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(this.cleanData);
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (!key.startsWith('@odata.')) {
|
||||
acc[key] = this.cleanData(value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map provider names to their specific data mappers.
|
||||
*/
|
||||
const PROVIDER_MAPPERS = {
|
||||
microsoft: MicrosoftDataMapper,
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstraction layer that returns a provider-specific mapper instance.
|
||||
*/
|
||||
class OpenIdDataMapper {
|
||||
/**
|
||||
* Retrieve an instance of the mapper for the specified provider.
|
||||
*
|
||||
* @param {string} provider - The name of the provider (e.g., 'microsoft').
|
||||
* @returns {BaseDataMapper} An instance of the specific data mapper for the provider.
|
||||
* @throws {Error} Throws an error if no mapper is found for the specified provider.
|
||||
*/
|
||||
static getMapper(provider) {
|
||||
const MapperClass = PROVIDER_MAPPERS[provider.toLowerCase()];
|
||||
if (!MapperClass) {
|
||||
throw new Error(`No mapper found for provider: ${provider}`);
|
||||
}
|
||||
return new MapperClass();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OpenIdDataMapper;
|
||||
@@ -8,8 +8,6 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||
const { hashToken } = require('~/server/utils/crypto');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const OpenIdDataMapper = require('./OpenId/openidDataMapper');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
@@ -22,27 +20,37 @@ try {
|
||||
* Downloads an image from a URL using an access token.
|
||||
* @param {string} url
|
||||
* @param {string} accessToken
|
||||
* @returns {Promise<Buffer|string>}
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
const downloadImage = async (url, accessToken) => {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
...(process.env.PROXY && { agent: new HttpsProxyAgent(process.env.PROXY) }),
|
||||
};
|
||||
|
||||
try {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.agent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
|
||||
if (response.ok) {
|
||||
const buffer = await response.buffer();
|
||||
return buffer;
|
||||
} else {
|
||||
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
||||
}
|
||||
return await response.buffer();
|
||||
} catch (error) {
|
||||
logger.error(`[openidStrategy] Error downloading image at URL "${url}": ${error}`);
|
||||
logger.error(
|
||||
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
@@ -57,21 +65,25 @@ const downloadImage = async (url, accessToken) => {
|
||||
* @param {string} [userinfo.email] - The user's email address
|
||||
* @returns {string} The determined full name of the user
|
||||
*/
|
||||
const getFullName = (userinfo) => {
|
||||
function getFullName(userinfo) {
|
||||
if (process.env.OPENID_NAME_CLAIM) {
|
||||
return userinfo[process.env.OPENID_NAME_CLAIM];
|
||||
}
|
||||
|
||||
if (userinfo.given_name && userinfo.family_name) {
|
||||
return `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
}
|
||||
|
||||
if (userinfo.given_name) {
|
||||
return userinfo.given_name;
|
||||
}
|
||||
|
||||
if (userinfo.family_name) {
|
||||
return userinfo.family_name;
|
||||
}
|
||||
|
||||
return userinfo.username || userinfo.email;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an input into a string suitable for a username.
|
||||
@@ -83,93 +95,26 @@ const getFullName = (userinfo) => {
|
||||
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
||||
* @returns {string} The processed input as a string suitable for a username.
|
||||
*/
|
||||
const convertToUsername = (input, defaultValue = '') => {
|
||||
function convertToUsername(input, defaultValue = '') {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
} else if (Array.isArray(input)) {
|
||||
return input.join('_');
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely decodes a JWT token.
|
||||
* @param {string} token
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
const safeDecode = (token) => {
|
||||
try {
|
||||
const decoded = jwtDecode(token);
|
||||
if (decoded && typeof decoded === 'object') {
|
||||
return decoded;
|
||||
}
|
||||
logger.error('[openidStrategy] Decoded token is not an object.');
|
||||
} catch (error) {
|
||||
logger.error('[openidStrategy] Error decoding token:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts roles from a decoded token based on the provided path.
|
||||
* @param {Object} decodedToken
|
||||
* @param {string} parameterPath
|
||||
* @returns {string[]}
|
||||
*/
|
||||
const extractRolesFromToken = (decodedToken, parameterPath) => {
|
||||
if (!decodedToken) {
|
||||
return [];
|
||||
}
|
||||
if (!parameterPath) {
|
||||
return [];
|
||||
}
|
||||
const roles = parameterPath.split('.').reduce((obj, key) => obj?.[key] ?? null, decodedToken);
|
||||
if (!Array.isArray(roles)) {
|
||||
logger.error('[openidStrategy] Roles extracted from token are not in array format.');
|
||||
return [];
|
||||
}
|
||||
return roles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the user's avatar if a valid picture URL is provided.
|
||||
* @param {Object} user
|
||||
* @param {string | undefined} pictureUrl - The URL of the user's avatar.
|
||||
* @param {string} accessToken
|
||||
* @returns {Promise<Object>} The updated user object.
|
||||
*/
|
||||
const updateUserAvatar = async (user, pictureUrl, accessToken) => {
|
||||
if (!pictureUrl || (user.avatar && user.avatar.includes('manual=true'))) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const fileName = crypto ? (await hashToken(user.openidId)) + '.png' : `${user.openidId}.png`;
|
||||
|
||||
const imageBuffer = await downloadImage(pictureUrl, accessToken);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
}
|
||||
return user;
|
||||
};
|
||||
}
|
||||
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
// Configure proxy if defined.
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({ agent: proxyAgent });
|
||||
logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY}`);
|
||||
custom.setHttpOptionsDefaults({
|
||||
agent: proxyAgent,
|
||||
});
|
||||
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
|
||||
}
|
||||
|
||||
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
|
||||
|
||||
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
|
||||
- id_token_signed_response_alg // defaults to 'RS256'
|
||||
- request_object_signing_alg // defaults to 'RS256'
|
||||
@@ -183,113 +128,125 @@ async function setupOpenId() {
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
|
||||
};
|
||||
|
||||
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
|
||||
clientMetadata.id_token_signed_response_alg =
|
||||
issuer.id_token_signing_alg_values_supported?.[0] || 'RS256';
|
||||
}
|
||||
|
||||
const client = new issuer.Client(clientMetadata);
|
||||
|
||||
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
||||
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
||||
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
||||
const adminRolesEnv = process.env.OPENID_ADMIN_ROLE;
|
||||
const adminRoles = adminRolesEnv ? adminRolesEnv.split(',').map((role) => role.trim()) : [];
|
||||
|
||||
const openidLogin = new OpenIDStrategy(
|
||||
{
|
||||
client,
|
||||
params: { scope: process.env.OPENID_SCOPE },
|
||||
params: {
|
||||
scope: process.env.OPENID_SCOPE,
|
||||
},
|
||||
},
|
||||
async (tokenset, userinfo, done) => {
|
||||
try {
|
||||
logger.info(`[openidStrategy] Verifying login for openidId: ${userinfo.sub}`);
|
||||
logger.debug('[openidStrategy] Tokenset and userinfo:', { tokenset, userinfo });
|
||||
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
|
||||
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
|
||||
|
||||
// Find an existing user by openidId or email.
|
||||
let user =
|
||||
(await findUser({ openidId: userinfo.sub })) ||
|
||||
(await findUser({ email: userinfo.email }));
|
||||
|
||||
const fullName = getFullName(userinfo);
|
||||
const username = process.env.OPENID_USERNAME_CLAIM
|
||||
? userinfo[process.env.OPENID_USERNAME_CLAIM]
|
||||
: convertToUsername(userinfo.username || userinfo.given_name || userinfo.email);
|
||||
|
||||
// Use the token specified by configuration to extract roles.
|
||||
const token =
|
||||
requiredRoleTokenKind === 'access' ? tokenset.access_token : tokenset.id_token;
|
||||
const decodedToken = safeDecode(token);
|
||||
const tokenBasedRoles = extractRolesFromToken(decodedToken, requiredRoleParameterPath);
|
||||
|
||||
// Ensure the required role exists.
|
||||
if (requiredRole && !tokenBasedRoles.includes(requiredRole)) {
|
||||
return done(null, false, {
|
||||
message: `You must have the "${requiredRole}" role to log in.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine system role.
|
||||
const isAdmin = tokenBasedRoles.some((role) => adminRoles.includes(role));
|
||||
const assignedRole = isAdmin ? SystemRoles.ADMIN : SystemRoles.USER;
|
||||
logger.debug(
|
||||
`[openidStrategy] Assigned system role: ${assignedRole} (isAdmin: ${isAdmin})`,
|
||||
let user = await findUser({ openidId: userinfo.sub });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${userinfo.sub}`,
|
||||
);
|
||||
|
||||
// Map custom OpenID data if configured.
|
||||
let customOpenIdData = {};
|
||||
if (process.env.OPENID_CUSTOM_DATA) {
|
||||
const dataMapper = OpenIdDataMapper.getMapper(
|
||||
process.env.OPENID_PROVIDER.toLowerCase(),
|
||||
if (!user) {
|
||||
user = await findUser({ email: userinfo.email });
|
||||
logger.info(
|
||||
`[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${
|
||||
userinfo.email
|
||||
} for openidId: ${userinfo.sub}`,
|
||||
);
|
||||
customOpenIdData = await dataMapper.mapCustomData(
|
||||
tokenset.access_token,
|
||||
process.env.OPENID_CUSTOM_DATA,
|
||||
);
|
||||
if (tokenBasedRoles.length) {
|
||||
customOpenIdData.roles = tokenBasedRoles;
|
||||
} else {
|
||||
logger.warn('[openidStrategy] tokenBasedRoles is missing or invalid.');
|
||||
}
|
||||
|
||||
const fullName = getFullName(userinfo);
|
||||
|
||||
if (requiredRole) {
|
||||
let decodedToken = '';
|
||||
if (requiredRoleTokenKind === 'access') {
|
||||
decodedToken = jwtDecode(tokenset.access_token);
|
||||
} 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);
|
||||
|
||||
if (!found) {
|
||||
logger.error(
|
||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!roles.includes(requiredRole)) {
|
||||
return done(null, false, {
|
||||
message: `You must have the "${requiredRole}" role to log in.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update the user.
|
||||
if (!user) {
|
||||
user = await createUser(
|
||||
{
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
role: assignedRole,
|
||||
customOpenIdData,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
let username = '';
|
||||
if (process.env.OPENID_USERNAME_CLAIM) {
|
||||
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
|
||||
} else {
|
||||
username = convertToUsername(
|
||||
userinfo.username || userinfo.given_name || userinfo.email,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = {
|
||||
...user,
|
||||
provider: 'openid',
|
||||
openidId: userinfo.sub,
|
||||
username,
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
role: assignedRole,
|
||||
customOpenIdData,
|
||||
};
|
||||
user = await createUser(user, true, true);
|
||||
} else {
|
||||
user.provider = 'openid';
|
||||
user.openidId = userinfo.sub;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
}
|
||||
|
||||
// Update the user's avatar if available.
|
||||
user = await updateUserAvatar(user, userinfo.picture, tokenset.access_token);
|
||||
if (userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
/** @type {string | undefined} */
|
||||
const imageUrl = userinfo.picture;
|
||||
|
||||
let fileName;
|
||||
if (crypto) {
|
||||
fileName = (await hashToken(userinfo.sub)) + '.png';
|
||||
} else {
|
||||
fileName = userinfo.sub + '.png';
|
||||
}
|
||||
|
||||
const imageBuffer = await downloadImage(imageUrl, tokenset.access_token);
|
||||
if (imageBuffer) {
|
||||
const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER);
|
||||
const imagePath = await saveBuffer({
|
||||
fileName,
|
||||
userId: user._id.toString(),
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
user.avatar = imagePath ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated user data.
|
||||
user = await updateUser(user._id, user);
|
||||
|
||||
logger.info(
|
||||
`[openidStrategy] Login success for openidId: ${user.openidId} | email: ${user.email} | username: ${user.username}`,
|
||||
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
||||
{
|
||||
user: {
|
||||
openidId: user.openidId,
|
||||
@@ -299,9 +256,10 @@ async function setupOpenId() {
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
done(null, user);
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] Login failed', err);
|
||||
logger.error('[openidStrategy] login failed', err);
|
||||
done(err);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
|
||||
const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||
const setupOpenId = require('./openidStrategy');
|
||||
const OpenIdDataMapper = require('./OpenId/openidDataMapper');
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('node-fetch');
|
||||
@@ -11,6 +10,7 @@ jest.mock('openid-client');
|
||||
jest.mock('jsonwebtoken/decode');
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(() => ({
|
||||
// You can modify this mock as needed (here returning a dummy function)
|
||||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||||
})),
|
||||
}));
|
||||
@@ -23,7 +23,7 @@ jest.mock('~/server/utils/crypto', () => ({
|
||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||
}));
|
||||
jest.mock('~/server/utils', () => ({
|
||||
isEnabled: jest.fn(() => false),
|
||||
isEnabled: jest.fn(() => false), // default to false, override per test if needed
|
||||
}));
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
@@ -43,7 +43,7 @@ Issuer.discover = jest.fn().mockResolvedValue({
|
||||
}),
|
||||
});
|
||||
|
||||
// Capture the verify callback from the strategy via the mock constructor
|
||||
// To capture the verify callback from the strategy, we grab it from the mock constructor
|
||||
let verifyCallback;
|
||||
OpenIDStrategy.mockImplementation((options, verify) => {
|
||||
verifyCallback = verify;
|
||||
@@ -80,6 +80,7 @@ describe('setupOpenId', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear previous mock calls and reset implementations
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset environment variables needed by the strategy
|
||||
@@ -95,8 +96,6 @@ describe('setupOpenId', () => {
|
||||
delete process.env.OPENID_USERNAME_CLAIM;
|
||||
delete process.env.OPENID_NAME_CLAIM;
|
||||
delete process.env.PROXY;
|
||||
delete process.env.OPENID_CUSTOM_DATA;
|
||||
delete process.env.OPENID_PROVIDER;
|
||||
|
||||
// Default jwtDecode mock returns a token that includes the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
@@ -126,8 +125,13 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should create a new user with correct username when username claim exists', async () => {
|
||||
// Arrange – our userinfo already has username 'flast'
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
expect(user.username).toBe(userinfo.username);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -143,10 +147,16 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should use given_name as username when username claim is missing', async () => {
|
||||
// Arrange – remove username from userinfo
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.username;
|
||||
// Expect the username to be the given name (unchanged case)
|
||||
const expectUsername = userinfo.given_name;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
expect(user.username).toBe(expectUsername);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: expectUsername }),
|
||||
@@ -156,11 +166,16 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should use email as username when username and given_name are missing', async () => {
|
||||
// Arrange – remove username and given_name
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.username;
|
||||
delete userinfo.given_name;
|
||||
const expectUsername = userinfo.email;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
expect(user.username).toBe(expectUsername);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: expectUsername }),
|
||||
@@ -170,9 +185,14 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should override username with OPENID_USERNAME_CLAIM when set', async () => {
|
||||
// Arrange – set OPENID_USERNAME_CLAIM so that the sub claim is used
|
||||
process.env.OPENID_USERNAME_CLAIM = 'sub';
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – username should equal the sub (converted as-is)
|
||||
expect(user.username).toBe(userinfo.sub);
|
||||
expect(createUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: userinfo.sub }),
|
||||
@@ -182,20 +202,31 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should set the full name correctly when given_name and family_name exist', async () => {
|
||||
// Arrange
|
||||
const userinfo = { ...baseUserinfo };
|
||||
const expectedFullName = `${userinfo.given_name} ${userinfo.family_name}`;
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
expect(user.name).toBe(expectedFullName);
|
||||
});
|
||||
|
||||
it('should override full name with OPENID_NAME_CLAIM when set', async () => {
|
||||
// Arrange – use the name claim as the full name
|
||||
process.env.OPENID_NAME_CLAIM = 'name';
|
||||
const userinfo = { ...baseUserinfo, name: 'Custom Name' };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert
|
||||
expect(user.name).toBe('Custom Name');
|
||||
});
|
||||
|
||||
it('should update an existing user on login', async () => {
|
||||
// Arrange – simulate that a user already exists
|
||||
const existingUser = {
|
||||
_id: 'existingUserId',
|
||||
provider: 'local',
|
||||
@@ -210,8 +241,13 @@ describe('setupOpenId', () => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – updateUser should be called and the user object updated
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
existingUser._id,
|
||||
expect.objectContaining({
|
||||
@@ -224,41 +260,43 @@ describe('setupOpenId', () => {
|
||||
});
|
||||
|
||||
it('should enforce the required role and reject login if missing', async () => {
|
||||
// Arrange – simulate a token without the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['SomeOtherRole'],
|
||||
});
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user, details } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – verify that the strategy rejects login
|
||||
expect(user).toBe(false);
|
||||
expect(details.message).toBe('You must have the "requiredRole" role to log in.');
|
||||
});
|
||||
|
||||
it('should attempt to download and save the avatar if picture is provided', async () => {
|
||||
// Arrange – ensure userinfo contains a picture URL
|
||||
const userinfo = { ...baseUserinfo };
|
||||
|
||||
// Act
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – verify that download was attempted and the avatar field was set via updateUser
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
// Our mock getStrategyFunctions.saveBuffer returns '/fake/path/to/avatar.png'
|
||||
expect(user.avatar).toBe('/fake/path/to/avatar.png');
|
||||
});
|
||||
|
||||
it('should not attempt to download avatar if picture is not provided', async () => {
|
||||
// Arrange – remove picture
|
||||
const userinfo = { ...baseUserinfo };
|
||||
delete userinfo.picture;
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
|
||||
// Act
|
||||
await validate(tokenset, userinfo);
|
||||
|
||||
// Assert – fetch should not be called and avatar should remain undefined or empty
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(user.avatar).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should map customOpenIdData as an object when OPENID_CUSTOM_DATA is set', async () => {
|
||||
process.env.OPENID_CUSTOM_DATA = 'some,fields';
|
||||
process.env.OPENID_PROVIDER = 'microsoft';
|
||||
const fakeCustomData = { foo: 'bar' };
|
||||
const fakeDataMapper = { mapCustomData: jest.fn().mockResolvedValue(fakeCustomData) };
|
||||
OpenIdDataMapper.getMapper = jest.fn(() => fakeDataMapper);
|
||||
|
||||
const userinfo = { ...baseUserinfo };
|
||||
const { user } = await validate(tokenset, userinfo);
|
||||
expect(OpenIdDataMapper.getMapper).toHaveBeenCalledWith('microsoft');
|
||||
expect(fakeDataMapper.mapCustomData).toHaveBeenCalledWith(tokenset.access_token, 'some,fields');
|
||||
expect(user.customOpenIdData).toEqual({ ...fakeCustomData, roles: ['requiredRole'] });
|
||||
// Depending on your implementation, user.avatar may be undefined or an empty string.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
||||
import type { TAuthContext } from '~/common';
|
||||
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
|
||||
import { ThemeContext, useLocalize } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TLoginFormProps = {
|
||||
onSubmit: (data: TLoginUser) => void;
|
||||
@@ -15,7 +14,6 @@ type TLoginFormProps = {
|
||||
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const {
|
||||
register,
|
||||
getValues,
|
||||
@@ -23,11 +21,9 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
formState: { errors },
|
||||
} = useForm<TLoginUser>();
|
||||
const [showResendLink, setShowResendLink] = useState<boolean>(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const useUsernameLogin = config?.ldap?.username;
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
useEffect(() => {
|
||||
if (error && error.includes('422') && !showResendLink) {
|
||||
@@ -163,29 +159,11 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
{localize('com_auth_password_forgot')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Render Turnstile only if enabled in startupConfig */}
|
||||
{startupConfig.turnstile && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile.siteKey}
|
||||
options={{
|
||||
...startupConfig.turnstile.options,
|
||||
theme: validTheme,
|
||||
}}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
onError={() => setTurnstileToken(null)}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
type="submit"
|
||||
disabled={startupConfig.turnstile ? !turnstileToken : false}
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from './ErrorMessage';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
|
||||
const Registration: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const {
|
||||
@@ -26,12 +24,10 @@ const Registration: React.FC = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number>(3);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const token = queryParams.get('token');
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
const registerUser = useRegisterUserMutation({
|
||||
onMutate: () => {
|
||||
@@ -182,38 +178,17 @@ const Registration: React.FC = () => {
|
||||
validate: (value: string) =>
|
||||
value === password || localize('com_auth_password_not_match'),
|
||||
})}
|
||||
|
||||
{/* Render Turnstile only if enabled in startupConfig */}
|
||||
{startupConfig?.turnstile && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile.siteKey}
|
||||
options={{
|
||||
...startupConfig.turnstile.options,
|
||||
theme: validTheme,
|
||||
}}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
onError={() => setTurnstileToken(null)}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
disabled={
|
||||
Object.keys(errors).length > 0 ||
|
||||
isSubmitting ||
|
||||
(startupConfig?.turnstile ? !turnstileToken : false)
|
||||
}
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
type="submit"
|
||||
aria-label="Submit registration"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
>
|
||||
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
|
||||
</button>
|
||||
|
||||
@@ -50,12 +50,10 @@ export default function AgentFooter({
|
||||
return localize('com_ui_create');
|
||||
};
|
||||
|
||||
const showButtons = activePanel === Panel.builder;
|
||||
|
||||
return (
|
||||
<div className="mx-1 mb-1 flex w-full flex-col gap-2">
|
||||
{showButtons && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{activePanel !== Panel.advanced && <AdvancedButton setActivePanel={setActivePanel} />}
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DeleteButton
|
||||
@@ -65,13 +63,13 @@ export default function AgentFooter({
|
||||
/>
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
||||
hasAccessToShareAgents && (
|
||||
<ShareAgent
|
||||
agent_id={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
<ShareAgent
|
||||
agent_id={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,6 @@ import './style.css';
|
||||
import './mobile.css';
|
||||
import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'katex/dist/contrib/copy-tex.js';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -71,12 +71,16 @@ interface:
|
||||
multiConvo: true
|
||||
agents: true
|
||||
|
||||
# Example Cloudflare turnstile (optional)
|
||||
#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"
|
||||
# Example Custom Token Rates (optional)
|
||||
#tokenRates:
|
||||
# gpt-4o-mini:
|
||||
# prompt: 200.0
|
||||
# completion: 400.0
|
||||
# claude-3.7-sonnet:
|
||||
# Cache:
|
||||
# read: 200.0
|
||||
# write: 400.0
|
||||
|
||||
|
||||
# Example Registration Object Structure (optional)
|
||||
registration:
|
||||
|
||||
121
package-lock.json
generated
121
package-lock.json
generated
@@ -66,7 +66,6 @@
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.317",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -109,7 +108,7 @@
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^4.96.2",
|
||||
"openai-chat-tokens": "^0.2.8",
|
||||
"openid-client": "^5.7.1",
|
||||
"openid-client": "^5.4.2",
|
||||
"passport": "^0.6.0",
|
||||
"passport-apple": "^2.0.2",
|
||||
"passport-discord": "^0.1.4",
|
||||
@@ -815,15 +814,6 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"api/node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"api/node_modules/keyv-file": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz",
|
||||
@@ -839,18 +829,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"api/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"api/node_modules/mongodb": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz",
|
||||
@@ -1015,21 +993,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"api/node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"api/node_modules/sharp": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||
@@ -1099,12 +1062,6 @@
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"api/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"client": {
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.7.8",
|
||||
@@ -1116,7 +1073,6 @@
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
@@ -19773,16 +19729,6 @@
|
||||
"resolved": "client",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@marsidev/react-turnstile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz",
|
||||
"integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/eslint-formatter-sarif": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/eslint-formatter-sarif/-/eslint-formatter-sarif-3.1.0.tgz",
|
||||
@@ -20002,33 +19948,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/microsoft-graph-client": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz",
|
||||
"integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"tslib": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@azure/identity": {
|
||||
"optional": true
|
||||
},
|
||||
"@azure/msal-browser": {
|
||||
"optional": true
|
||||
},
|
||||
"buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"stream-browserify": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mistralai/mistralai": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.5.2.tgz",
|
||||
@@ -33612,6 +33531,14 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.5",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
|
||||
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz",
|
||||
@@ -37115,6 +37042,36 @@
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.6.4",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz",
|
||||
"integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.4",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.3",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
|
||||
|
||||
@@ -505,28 +505,10 @@ export const intefaceSchema = z
|
||||
export type TInterfaceConfig = z.infer<typeof intefaceSchema>;
|
||||
export type TBalanceConfig = z.infer<typeof balanceSchema>;
|
||||
|
||||
export const turnstileOptionsSchema = z
|
||||
.object({
|
||||
language: z.string().default('auto'),
|
||||
size: z.enum(['normal', 'compact', 'flexible', 'invisible']).default('normal'),
|
||||
})
|
||||
.default({
|
||||
language: 'auto',
|
||||
size: 'normal',
|
||||
});
|
||||
|
||||
export const turnstileSchema = z.object({
|
||||
siteKey: z.string(),
|
||||
options: turnstileOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export type TTurnstileConfig = z.infer<typeof turnstileSchema>;
|
||||
|
||||
export type TStartupConfig = {
|
||||
appTitle: string;
|
||||
socialLogins?: string[];
|
||||
interface?: TInterfaceConfig;
|
||||
turnstile?: TTurnstileConfig;
|
||||
balance?: TBalanceConfig;
|
||||
discordLoginEnabled: boolean;
|
||||
facebookLoginEnabled: boolean;
|
||||
@@ -554,6 +536,7 @@ export type TStartupConfig = {
|
||||
helpAndFaqURL: string;
|
||||
customFooter?: string;
|
||||
modelSpecs?: TSpecsConfig;
|
||||
tokenRates?: TModelTokenRates;
|
||||
sharedLinksEnabled: boolean;
|
||||
publicSharedLinksEnabled: boolean;
|
||||
analyticsGtmId?: string;
|
||||
@@ -562,6 +545,31 @@ export type TStartupConfig = {
|
||||
staticBundlerURL?: string;
|
||||
};
|
||||
|
||||
|
||||
// Token cost schema type
|
||||
export type TTokenCost = {
|
||||
prompt?: number;
|
||||
completion?: number;
|
||||
cache?: {
|
||||
write?: number;
|
||||
read?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// Endpoint token rates schema type
|
||||
export type TModelTokenRates = Record<string, TTokenCost>;
|
||||
|
||||
const tokenCostSchema = z.object({
|
||||
prompt: z.number().optional(), // e.g. 1.5 => $1.50 / 1M tokens
|
||||
completion: z.number().optional(), // e.g. 2.0 => $2.00 / 1M tokens
|
||||
cache: z
|
||||
.object({
|
||||
write: z.number().optional(),
|
||||
read: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export enum OCRStrategy {
|
||||
MISTRAL_OCR = 'mistral_ocr',
|
||||
CUSTOM_OCR = 'custom_ocr',
|
||||
@@ -596,7 +604,6 @@ export const configSchema = z.object({
|
||||
filteredTools: z.array(z.string()).optional(),
|
||||
mcpServers: MCPServersSchema.optional(),
|
||||
interface: intefaceSchema,
|
||||
turnstile: turnstileSchema.optional(),
|
||||
fileStrategy: fileSourceSchema.default(FileSources.local),
|
||||
actions: z
|
||||
.object({
|
||||
@@ -620,6 +627,7 @@ export const configSchema = z.object({
|
||||
rateLimits: rateLimitSchema.optional(),
|
||||
fileConfig: fileConfigSchema.optional(),
|
||||
modelSpecs: specsConfigSchema.optional(),
|
||||
tokenRates: tokenCostSchema.optional(),
|
||||
endpoints: z
|
||||
.object({
|
||||
all: baseEndpointSchema.optional(),
|
||||
|
||||
@@ -128,7 +128,6 @@ export type TUser = {
|
||||
backupCodes?: TBackupCode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
customOpenIdData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TGetConversationsResponse = {
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface IUser extends Document {
|
||||
googleId?: string;
|
||||
facebookId?: string;
|
||||
openidId?: string;
|
||||
customOpenIdData?: Record<string, unknown>;
|
||||
ldapId?: string;
|
||||
githubId?: string;
|
||||
discordId?: string;
|
||||
@@ -113,10 +112,6 @@ const User = new Schema<IUser>(
|
||||
unique: true,
|
||||
sparse: true,
|
||||
},
|
||||
customOpenIdData: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: undefined,
|
||||
},
|
||||
ldapId: {
|
||||
type: String,
|
||||
unique: true,
|
||||
|
||||
Reference in New Issue
Block a user