mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
🛜 refactor: Streamline App Config Usage (#9234)
* WIP: app.locals refactoring
WIP: appConfig
fix: update memory configuration retrieval to use getAppConfig based on user role
fix: update comment for AppConfig interface to clarify purpose
🏷️ refactor: Update tests to use getAppConfig for endpoint configurations
ci: Update AppService tests to initialize app config instead of app.locals
ci: Integrate getAppConfig into remaining tests
refactor: Update multer storage destination to use promise-based getAppConfig and improve error handling in tests
refactor: Rename initializeAppConfig to setAppConfig and update related tests
ci: Mock getAppConfig in various tests to provide default configurations
refactor: Update convertMCPToolsToPlugins to use mcpManager for server configuration and adjust related tests
chore: rename `Config/getAppConfig` -> `Config/app`
fix: streamline OpenAI image tools configuration by removing direct appConfig dependency and using function parameters
chore: correct parameter documentation for imageOutputType in ToolService.js
refactor: remove `getCustomConfig` dependency in config route
refactor: update domain validation to use appConfig for allowed domains
refactor: use appConfig registration property
chore: remove app parameter from AppService invocation
refactor: update AppConfig interface to correct registration and turnstile configurations
refactor: remove getCustomConfig dependency and use getAppConfig in PluginController, multer, and MCP services
refactor: replace getCustomConfig with getAppConfig in STTService, TTSService, and related files
refactor: replace getCustomConfig with getAppConfig in Conversation and Message models, update tempChatRetention functions to use AppConfig type
refactor: update getAppConfig calls in Conversation and Message models to include user role for temporary chat expiration
ci: update related tests
refactor: update getAppConfig call in getCustomConfigSpeech to include user role
fix: update appConfig usage to access allowedDomains from actions instead of registration
refactor: enhance AppConfig to include fileStrategies and update related file strategy logic
refactor: update imports to use normalizeEndpointName from @librechat/api and remove redundant definitions
chore: remove deprecated unused RunManager
refactor: get balance config primarily from appConfig
refactor: remove customConfig dependency for appConfig and streamline loadConfigModels logic
refactor: remove getCustomConfig usage and use app config in file citations
refactor: consolidate endpoint loading logic into loadEndpoints function
refactor: update appConfig access to use endpoints structure across various services
refactor: implement custom endpoints configuration and streamline endpoint loading logic
refactor: update getAppConfig call to include user role parameter
refactor: streamline endpoint configuration and enhance appConfig usage across services
refactor: replace getMCPAuthMap with getUserMCPAuthMap and remove unused getCustomConfig file
refactor: add type annotation for loadedEndpoints in loadEndpoints function
refactor: move /services/Files/images/parse to TS API
chore: add missing FILE_CITATIONS permission to IRole interface
refactor: restructure toolkits to TS API
refactor: separate manifest logic into its own module
refactor: consolidate tool loading logic into a new tools module for startup logic
refactor: move interface config logic to TS API
refactor: migrate checkEmailConfig to TypeScript and update imports
refactor: add FunctionTool interface and availableTools to AppConfig
refactor: decouple caching and DB operations from AppService, make part of consolidated `getAppConfig`
WIP: fix tests
* fix: rebase conflicts
* refactor: remove app.locals references
* refactor: replace getBalanceConfig with getAppConfig in various strategies and middleware
* refactor: replace appConfig?.balance with getBalanceConfig in various controllers and clients
* test: add balance configuration to titleConvo method in AgentClient tests
* chore: remove unused `openai-chat-tokens` package
* chore: remove unused imports in initializeMCPs.js
* refactor: update balance configuration to use getAppConfig instead of getBalanceConfig
* refactor: integrate configMiddleware for centralized configuration handling
* refactor: optimize email domain validation by removing unnecessary async calls
* refactor: simplify multer storage configuration by removing async calls
* refactor: reorder imports for better readability in user.js
* refactor: replace getAppConfig calls with req.config for improved performance
* chore: replace getAppConfig calls with req.config in tests for centralized configuration handling
* chore: remove unused override config
* refactor: add configMiddleware to endpoint route and replace getAppConfig with req.config
* chore: remove customConfig parameter from TTSService constructor
* refactor: pass appConfig from request to processFileCitations for improved configuration handling
* refactor: remove configMiddleware from endpoint route and retrieve appConfig directly in getEndpointsConfig if not in `req.config`
* test: add mockAppConfig to processFileCitations tests for improved configuration handling
* fix: pass req.config to hasCustomUserVars and call without await after synchronous refactor
* fix: type safety in useExportConversation
* refactor: retrieve appConfig using getAppConfig in PluginController and remove configMiddleware from plugins route, to avoid always retrieving when plugins are cached
* chore: change `MongoUser` typedef to `IUser`
* fix: Add `user` and `config` fields to ServerRequest and update JSDoc type annotations from Express.Request to ServerRequest
* fix: remove unused setAppConfig mock from Server configuration tests
This commit is contained in:
parent
e1ad235f17
commit
9a210971f5
210 changed files with 4102 additions and 3465 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { primeResources } from './resources';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint, TFile } from 'librechat-data-provider';
|
||||
import type { Request as ServerRequest } from 'express';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { TGetFiles } from './resources';
|
||||
import type { AppConfig } from '~/types';
|
||||
|
||||
// Mock logger
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -13,7 +15,8 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
}));
|
||||
|
||||
describe('primeResources', () => {
|
||||
let mockReq: ServerRequest;
|
||||
let mockReq: ServerRequest & { user?: IUser };
|
||||
let mockAppConfig: AppConfig;
|
||||
let mockGetFiles: jest.MockedFunction<TGetFiles>;
|
||||
let requestFileSet: Set<string>;
|
||||
|
||||
|
|
@ -22,15 +25,16 @@ describe('primeResources', () => {
|
|||
jest.clearAllMocks();
|
||||
|
||||
// Setup mock request
|
||||
mockReq = {
|
||||
app: {
|
||||
locals: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.ocr],
|
||||
},
|
||||
},
|
||||
mockReq = {} as unknown as ServerRequest & { user?: IUser };
|
||||
|
||||
// Setup mock appConfig
|
||||
mockAppConfig = {
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.ocr],
|
||||
} as TAgentsEndpoint,
|
||||
},
|
||||
} as unknown as ServerRequest;
|
||||
} as AppConfig;
|
||||
|
||||
// Setup mock getFiles function
|
||||
mockGetFiles = jest.fn();
|
||||
|
|
@ -65,6 +69,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments: undefined,
|
||||
|
|
@ -84,7 +89,7 @@ describe('primeResources', () => {
|
|||
|
||||
describe('when OCR is disabled', () => {
|
||||
it('should not fetch OCR files even if tool_resources has OCR file_ids', async () => {
|
||||
(mockReq.app as ServerRequest['app']).locals[EModelEndpoint.agents].capabilities = [];
|
||||
(mockAppConfig.endpoints![EModelEndpoint.agents] as TAgentsEndpoint).capabilities = [];
|
||||
|
||||
const tool_resources = {
|
||||
[EToolResources.ocr]: {
|
||||
|
|
@ -94,6 +99,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments: undefined,
|
||||
|
|
@ -129,6 +135,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -158,6 +165,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -189,6 +197,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -220,6 +229,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -250,6 +260,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -291,6 +302,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -342,6 +354,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -399,6 +412,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -450,6 +464,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -492,6 +507,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -560,6 +576,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -618,6 +635,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -671,6 +689,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -724,6 +743,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -764,6 +784,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -838,6 +859,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -888,6 +910,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -906,6 +929,7 @@ describe('primeResources', () => {
|
|||
// The function should now handle rejected attachment promises gracefully
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments,
|
||||
|
|
@ -926,11 +950,13 @@ describe('primeResources', () => {
|
|||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing app.locals gracefully', async () => {
|
||||
const reqWithoutLocals = {} as ServerRequest;
|
||||
it('should handle missing appConfig agents endpoint gracefully', async () => {
|
||||
const reqWithoutLocals = {} as ServerRequest & { user?: IUser };
|
||||
const emptyAppConfig = {} as AppConfig;
|
||||
|
||||
const result = await primeResources({
|
||||
req: reqWithoutLocals,
|
||||
appConfig: emptyAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments: undefined,
|
||||
|
|
@ -942,14 +968,15 @@ describe('primeResources', () => {
|
|||
});
|
||||
|
||||
expect(mockGetFiles).not.toHaveBeenCalled();
|
||||
// When app.locals is missing and there's an error accessing properties,
|
||||
// the function falls back to the catch block which returns an empty array
|
||||
expect(result.attachments).toEqual([]);
|
||||
// When appConfig agents endpoint is missing, OCR is disabled
|
||||
// and no attachments are provided, the function returns undefined
|
||||
expect(result.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined tool_resources', async () => {
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet,
|
||||
attachments: undefined,
|
||||
|
|
@ -982,6 +1009,7 @@ describe('primeResources', () => {
|
|||
|
||||
const result = await primeResources({
|
||||
req: mockReq,
|
||||
appConfig: mockAppConfig,
|
||||
getFiles: mockGetFiles,
|
||||
requestFileSet: emptyRequestFileSet,
|
||||
attachments,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-dat
|
|||
import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose';
|
||||
import type { IMongoFile, IUser } from '@librechat/data-schemas';
|
||||
import type { Request as ServerRequest } from 'express';
|
||||
import type { AppConfig } from '~/types/';
|
||||
|
||||
/**
|
||||
* Function type for retrieving files from the database
|
||||
|
|
@ -134,7 +135,8 @@ const categorizeFileForToolResources = ({
|
|||
* 4. Prevents duplicate files across all sources
|
||||
*
|
||||
* @param params - Parameters object
|
||||
* @param params.req - Express request object containing app configuration
|
||||
* @param params.req - Express request object
|
||||
* @param params.appConfig - Application configuration object
|
||||
* @param params.getFiles - Function to retrieve files from database
|
||||
* @param params.requestFileSet - Set of file IDs from the current request
|
||||
* @param params.attachments - Promise resolving to array of attachment files
|
||||
|
|
@ -143,6 +145,7 @@ const categorizeFileForToolResources = ({
|
|||
*/
|
||||
export const primeResources = async ({
|
||||
req,
|
||||
appConfig,
|
||||
getFiles,
|
||||
requestFileSet,
|
||||
attachments: _attachments,
|
||||
|
|
@ -150,6 +153,7 @@ export const primeResources = async ({
|
|||
agentId,
|
||||
}: {
|
||||
req: ServerRequest & { user?: IUser };
|
||||
appConfig: AppConfig;
|
||||
requestFileSet: Set<string>;
|
||||
attachments: Promise<Array<TFile | null>> | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
|
|
@ -198,9 +202,9 @@ export const primeResources = async ({
|
|||
}
|
||||
}
|
||||
|
||||
const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes(
|
||||
AgentCapabilities.ocr,
|
||||
);
|
||||
const isOCREnabled = (
|
||||
appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities ?? []
|
||||
).includes(AgentCapabilities.ocr);
|
||||
|
||||
if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) {
|
||||
const context = await getFiles(
|
||||
|
|
|
|||
43
packages/api/src/app/config.ts
Normal file
43
packages/api/src/app/config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types';
|
||||
import { isEnabled, normalizeEndpointName } from '~/utils';
|
||||
|
||||
/**
|
||||
* Retrieves the balance configuration object
|
||||
* */
|
||||
export function getBalanceConfig(appConfig?: AppConfig): Partial<TCustomConfig['balance']> | null {
|
||||
const isLegacyEnabled = isEnabled(process.env.CHECK_BALANCE);
|
||||
const startBalance = process.env.START_BALANCE;
|
||||
/** @type {} */
|
||||
const config: Partial<TCustomConfig['balance']> = removeNullishValues({
|
||||
enabled: isLegacyEnabled,
|
||||
startBalance: startBalance != null && startBalance ? parseInt(startBalance, 10) : undefined,
|
||||
});
|
||||
if (!appConfig) {
|
||||
return config;
|
||||
}
|
||||
return { ...config, ...(appConfig?.['balance'] ?? {}) };
|
||||
}
|
||||
|
||||
export const getCustomEndpointConfig = ({
|
||||
endpoint,
|
||||
appConfig,
|
||||
}: {
|
||||
endpoint: string | EModelEndpoint;
|
||||
appConfig?: AppConfig;
|
||||
}): Partial<TEndpoint> | undefined => {
|
||||
if (!appConfig) {
|
||||
throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
|
||||
}
|
||||
|
||||
const customEndpoints = appConfig.endpoints?.[EModelEndpoint.custom] ?? [];
|
||||
return customEndpoints.find(
|
||||
(endpointConfig) => normalizeEndpointName(endpointConfig.name) === endpoint,
|
||||
);
|
||||
};
|
||||
|
||||
export function hasCustomUserVars(appConfig?: AppConfig): boolean {
|
||||
const mcpServers = appConfig?.mcpConfig;
|
||||
return Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
||||
}
|
||||
3
packages/api/src/app/index.ts
Normal file
3
packages/api/src/app/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './config';
|
||||
export * from './interface';
|
||||
export * from './permissions';
|
||||
108
packages/api/src/app/interface.ts
Normal file
108
packages/api/src/app/interface.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { removeNullishValues } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types/config';
|
||||
import { isMemoryEnabled } from '~/memory/config';
|
||||
|
||||
/**
|
||||
* Loads the default interface object.
|
||||
* @param params - The loaded custom configuration.
|
||||
* @param params.config - The loaded custom configuration.
|
||||
* @param params.configDefaults - The custom configuration default values.
|
||||
* @returns default interface object.
|
||||
*/
|
||||
export async function loadDefaultInterface({
|
||||
config,
|
||||
configDefaults,
|
||||
}: {
|
||||
config?: Partial<TCustomConfig>;
|
||||
configDefaults: TConfigDefaults;
|
||||
}): Promise<AppConfig['interfaceConfig']> {
|
||||
const { interface: interfaceConfig } = config ?? {};
|
||||
const { interface: defaults } = configDefaults;
|
||||
const hasModelSpecs = (config?.modelSpecs?.list?.length ?? 0) > 0;
|
||||
const includesAddedEndpoints = (config?.modelSpecs?.addedEndpoints?.length ?? 0) > 0;
|
||||
|
||||
const memoryConfig = config?.memory;
|
||||
const memoryEnabled = isMemoryEnabled(memoryConfig);
|
||||
/** Only disable memories if memory config is present but disabled/invalid */
|
||||
const shouldDisableMemories = memoryConfig && !memoryEnabled;
|
||||
|
||||
const loadedInterface: AppConfig['interfaceConfig'] = removeNullishValues({
|
||||
// UI elements - use schema defaults
|
||||
endpointsMenu:
|
||||
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
|
||||
modelSelect:
|
||||
interfaceConfig?.modelSelect ??
|
||||
(hasModelSpecs ? includesAddedEndpoints : defaults.modelSelect),
|
||||
parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters),
|
||||
presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets),
|
||||
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
|
||||
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
|
||||
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
|
||||
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
|
||||
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
|
||||
|
||||
// Permissions - only include if explicitly configured
|
||||
bookmarks: interfaceConfig?.bookmarks,
|
||||
memories: shouldDisableMemories ? false : interfaceConfig?.memories,
|
||||
prompts: interfaceConfig?.prompts,
|
||||
multiConvo: interfaceConfig?.multiConvo,
|
||||
agents: interfaceConfig?.agents,
|
||||
temporaryChat: interfaceConfig?.temporaryChat,
|
||||
runCode: interfaceConfig?.runCode,
|
||||
webSearch: interfaceConfig?.webSearch,
|
||||
fileSearch: interfaceConfig?.fileSearch,
|
||||
fileCitations: interfaceConfig?.fileCitations,
|
||||
peoplePicker: interfaceConfig?.peoplePicker,
|
||||
marketplace: interfaceConfig?.marketplace,
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
const logSettings = () => {
|
||||
// log interface object and model specs object (without list) for reference
|
||||
logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`);
|
||||
logger.warn(
|
||||
`\`modelSpecs\` settings:\n${JSON.stringify(
|
||||
{ ...(config?.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 (config?.modelSpecs?.prioritize && loadedInterface.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 (
|
||||
config?.modelSpecs?.enforce &&
|
||||
(loadedInterface.endpointsMenu ||
|
||||
loadedInterface.modelSelect ||
|
||||
loadedInterface.presets ||
|
||||
loadedInterface.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 (config?.modelSpecs?.enforce && !config?.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();
|
||||
}
|
||||
|
||||
return loadedInterface;
|
||||
}
|
||||
1159
packages/api/src/app/permissions.spec.ts
Normal file
1159
packages/api/src/app/permissions.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
234
packages/api/src/app/permissions.ts
Normal file
234
packages/api/src/app/permissions.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
getConfigDefaults,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole } from '@librechat/data-schemas';
|
||||
import type { AppConfig } from '~/types/config';
|
||||
import { isMemoryEnabled } from '~/memory/config';
|
||||
|
||||
/**
|
||||
* Checks if a permission type has explicit configuration
|
||||
*/
|
||||
function hasExplicitConfig(
|
||||
interfaceConfig: AppConfig['interfaceConfig'],
|
||||
permissionType: PermissionTypes,
|
||||
) {
|
||||
switch (permissionType) {
|
||||
case PermissionTypes.PROMPTS:
|
||||
return interfaceConfig?.prompts !== undefined;
|
||||
case PermissionTypes.BOOKMARKS:
|
||||
return interfaceConfig?.bookmarks !== undefined;
|
||||
case PermissionTypes.MEMORIES:
|
||||
return interfaceConfig?.memories !== undefined;
|
||||
case PermissionTypes.MULTI_CONVO:
|
||||
return interfaceConfig?.multiConvo !== undefined;
|
||||
case PermissionTypes.AGENTS:
|
||||
return interfaceConfig?.agents !== undefined;
|
||||
case PermissionTypes.TEMPORARY_CHAT:
|
||||
return interfaceConfig?.temporaryChat !== undefined;
|
||||
case PermissionTypes.RUN_CODE:
|
||||
return interfaceConfig?.runCode !== undefined;
|
||||
case PermissionTypes.WEB_SEARCH:
|
||||
return interfaceConfig?.webSearch !== undefined;
|
||||
case PermissionTypes.PEOPLE_PICKER:
|
||||
return interfaceConfig?.peoplePicker !== undefined;
|
||||
case PermissionTypes.MARKETPLACE:
|
||||
return interfaceConfig?.marketplace !== undefined;
|
||||
case PermissionTypes.FILE_SEARCH:
|
||||
return interfaceConfig?.fileSearch !== undefined;
|
||||
case PermissionTypes.FILE_CITATIONS:
|
||||
return interfaceConfig?.fileCitations !== undefined;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateInterfacePermissions({
|
||||
appConfig,
|
||||
getRoleByName,
|
||||
updateAccessPermissions,
|
||||
}: {
|
||||
appConfig: AppConfig;
|
||||
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
|
||||
updateAccessPermissions: (
|
||||
roleName: string,
|
||||
permissionsUpdate: Partial<Record<PermissionTypes, Record<string, boolean | undefined>>>,
|
||||
|
||||
roleData?: IRole | null,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const loadedInterface = appConfig?.interfaceConfig;
|
||||
if (!loadedInterface) {
|
||||
return;
|
||||
}
|
||||
/** Configured values for interface object structure */
|
||||
const interfaceConfig = appConfig?.config?.interface;
|
||||
const memoryConfig = appConfig?.config?.memory;
|
||||
const memoryEnabled = isMemoryEnabled(memoryConfig);
|
||||
/** Check if personalization is enabled (defaults to true if memory is configured and enabled) */
|
||||
const isPersonalizationEnabled =
|
||||
memoryConfig && memoryEnabled && memoryConfig.personalize !== false;
|
||||
|
||||
/** Helper to get permission value with proper precedence */
|
||||
const getPermissionValue = (
|
||||
configValue?: boolean,
|
||||
roleDefault?: boolean,
|
||||
schemaDefault?: boolean,
|
||||
) => {
|
||||
if (configValue !== undefined) return configValue;
|
||||
if (roleDefault !== undefined) return roleDefault;
|
||||
return schemaDefault;
|
||||
};
|
||||
|
||||
const defaults = getConfigDefaults().interface;
|
||||
|
||||
// Permission precedence order:
|
||||
// 1. Explicit user configuration (from librechat.yaml)
|
||||
// 2. Role-specific defaults (from roleDefaults)
|
||||
// 3. Interface schema defaults (from interfaceSchema.default())
|
||||
for (const roleName of [SystemRoles.USER, SystemRoles.ADMIN]) {
|
||||
const defaultPerms = roleDefaults[roleName]?.permissions;
|
||||
|
||||
const existingRole = await getRoleByName(roleName);
|
||||
const existingPermissions = existingRole?.permissions;
|
||||
const permissionsToUpdate: Partial<
|
||||
Record<PermissionTypes, Record<string, boolean | undefined>>
|
||||
> = {};
|
||||
|
||||
/**
|
||||
* Helper to add permission if it should be updated
|
||||
*/
|
||||
const addPermissionIfNeeded = (
|
||||
permType: PermissionTypes,
|
||||
permissions: Record<string, boolean | undefined>,
|
||||
) => {
|
||||
const permTypeExists = existingPermissions?.[permType];
|
||||
const isExplicitlyConfigured =
|
||||
interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
|
||||
|
||||
// Only update if: doesn't exist OR explicitly configured
|
||||
if (!permTypeExists || isExplicitlyConfigured) {
|
||||
permissionsToUpdate[permType] = permissions;
|
||||
if (!permTypeExists) {
|
||||
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
|
||||
} else if (isExplicitlyConfigured) {
|
||||
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const allPermissions: Partial<Record<PermissionTypes, Record<string, boolean | undefined>>> = {
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.prompts,
|
||||
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE],
|
||||
defaults.prompts,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.BOOKMARKS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.bookmarks,
|
||||
defaultPerms[PermissionTypes.BOOKMARKS]?.[Permissions.USE],
|
||||
defaults.bookmarks,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.memories,
|
||||
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
|
||||
defaults.memories,
|
||||
),
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.multiConvo,
|
||||
defaultPerms[PermissionTypes.MULTI_CONVO]?.[Permissions.USE],
|
||||
defaults.multiConvo,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.agents,
|
||||
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE],
|
||||
defaults.agents,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.TEMPORARY_CHAT]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.temporaryChat,
|
||||
defaultPerms[PermissionTypes.TEMPORARY_CHAT]?.[Permissions.USE],
|
||||
defaults.temporaryChat,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.RUN_CODE]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.runCode,
|
||||
defaultPerms[PermissionTypes.RUN_CODE]?.[Permissions.USE],
|
||||
defaults.runCode,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.WEB_SEARCH]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.webSearch,
|
||||
defaultPerms[PermissionTypes.WEB_SEARCH]?.[Permissions.USE],
|
||||
defaults.webSearch,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.PEOPLE_PICKER]: {
|
||||
[Permissions.VIEW_USERS]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.users,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_USERS],
|
||||
defaults.peoplePicker?.users,
|
||||
),
|
||||
[Permissions.VIEW_GROUPS]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.groups,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_GROUPS],
|
||||
defaults.peoplePicker?.groups,
|
||||
),
|
||||
[Permissions.VIEW_ROLES]: getPermissionValue(
|
||||
loadedInterface.peoplePicker?.roles,
|
||||
defaultPerms[PermissionTypes.PEOPLE_PICKER]?.[Permissions.VIEW_ROLES],
|
||||
defaults.peoplePicker?.roles,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.MARKETPLACE]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.marketplace?.use,
|
||||
defaultPerms[PermissionTypes.MARKETPLACE]?.[Permissions.USE],
|
||||
defaults.marketplace?.use,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.FILE_SEARCH]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.fileSearch,
|
||||
defaultPerms[PermissionTypes.FILE_SEARCH]?.[Permissions.USE],
|
||||
defaults.fileSearch,
|
||||
),
|
||||
},
|
||||
[PermissionTypes.FILE_CITATIONS]: {
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.fileCitations,
|
||||
defaultPerms[PermissionTypes.FILE_CITATIONS]?.[Permissions.USE],
|
||||
defaults.fileCitations,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
// Check and add each permission type if needed
|
||||
for (const [permType, permissions] of Object.entries(allPermissions)) {
|
||||
addPermissionIfNeeded(permType as PermissionTypes, permissions);
|
||||
}
|
||||
|
||||
// Update permissions if any need updating
|
||||
if (Object.keys(permissionsToUpdate).length > 0) {
|
||||
await updateAccessPermissions(roleName, permissionsToUpdate, existingRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
packages/api/src/endpoints/custom/config.ts
Normal file
56
packages/api/src/endpoints/custom/config.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { EModelEndpoint, extractEnvVariable } from 'librechat-data-provider';
|
||||
import type { TCustomEndpoints, TEndpoint, TConfig } from 'librechat-data-provider';
|
||||
import type { TCustomEndpointsConfig } from '~/types/endpoints';
|
||||
import { isUserProvided, normalizeEndpointName } from '~/utils';
|
||||
|
||||
/**
|
||||
* Load config endpoints from the cached configuration object
|
||||
* @param customEndpointsConfig - The configuration object
|
||||
*/
|
||||
export function loadCustomEndpointsConfig(
|
||||
customEndpoints?: TCustomEndpoints,
|
||||
): TCustomEndpointsConfig | undefined {
|
||||
if (!customEndpoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customEndpointsConfig: TCustomEndpointsConfig = {};
|
||||
|
||||
if (Array.isArray(customEndpoints)) {
|
||||
const filteredEndpoints = customEndpoints.filter(
|
||||
(endpoint) =>
|
||||
endpoint.baseURL &&
|
||||
endpoint.apiKey &&
|
||||
endpoint.name &&
|
||||
endpoint.models &&
|
||||
(endpoint.models.fetch || endpoint.models.default),
|
||||
);
|
||||
|
||||
for (let i = 0; i < filteredEndpoints.length; i++) {
|
||||
const endpoint = filteredEndpoints[i] as TEndpoint;
|
||||
const {
|
||||
baseURL,
|
||||
apiKey,
|
||||
name: configName,
|
||||
iconURL,
|
||||
modelDisplayLabel,
|
||||
customParams,
|
||||
} = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
|
||||
const resolvedApiKey = extractEnvVariable(apiKey ?? '');
|
||||
const resolvedBaseURL = extractEnvVariable(baseURL ?? '');
|
||||
|
||||
customEndpointsConfig[name] = {
|
||||
type: EModelEndpoint.custom,
|
||||
userProvide: isUserProvided(resolvedApiKey),
|
||||
userProvideURL: isUserProvided(resolvedBaseURL),
|
||||
customParams: customParams as TConfig['customParams'],
|
||||
modelDisplayLabel,
|
||||
iconURL,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return customEndpointsConfig;
|
||||
}
|
||||
1
packages/api/src/endpoints/custom/index.ts
Normal file
1
packages/api/src/endpoints/custom/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './config';
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './custom';
|
||||
export * from './google';
|
||||
export * from './openai';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } from 'librechat-data-provider';
|
||||
import type {
|
||||
UserKeyValues,
|
||||
InitializeOpenAIOptionsParams,
|
||||
OpenAIOptionsResult,
|
||||
OpenAIConfigOptions,
|
||||
InitializeOpenAIOptionsParams,
|
||||
UserKeyValues,
|
||||
} from '~/types';
|
||||
import { createHandleLLMNewToken } from '~/utils/generators';
|
||||
import { getAzureCredentials } from '~/utils/azure';
|
||||
|
|
@ -21,6 +21,7 @@ import { getOpenAIConfig } from './llm';
|
|||
*/
|
||||
export const initializeOpenAI = async ({
|
||||
req,
|
||||
appConfig,
|
||||
overrideModel,
|
||||
endpointOption,
|
||||
overrideEndpoint,
|
||||
|
|
@ -71,7 +72,7 @@ export const initializeOpenAI = async ({
|
|||
};
|
||||
|
||||
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
|
||||
const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI];
|
||||
const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
|
||||
|
||||
if (isAzureOpenAI && azureConfig) {
|
||||
const { modelGroupMap, groupMap } = azureConfig;
|
||||
|
|
@ -142,8 +143,8 @@ export const initializeOpenAI = async ({
|
|||
|
||||
const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint);
|
||||
|
||||
const openAIConfig = req.app.locals[EModelEndpoint.openAI];
|
||||
const allConfig = req.app.locals.all;
|
||||
const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI];
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
const azureRate = modelName?.includes('gpt-4') ? 30 : 17;
|
||||
|
||||
let streamRate: number | undefined;
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './mistral/crud';
|
||||
export * from './parse';
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ import * as fs from 'fs';
|
|||
import axios from 'axios';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import type { Readable } from 'stream';
|
||||
import type { MistralFileUploadResponse, MistralSignedUrlResponse, OCRResult } from '~/types';
|
||||
import type {
|
||||
MistralFileUploadResponse,
|
||||
MistralSignedUrlResponse,
|
||||
OCRResult,
|
||||
AppConfig,
|
||||
} from '~/types';
|
||||
import { logger as mockLogger } from '@librechat/data-schemas';
|
||||
import {
|
||||
uploadDocumentToMistral,
|
||||
|
|
@ -497,18 +502,17 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
// Use environment variable syntax to ensure loadAuthValues is called
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-medium',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
// Use environment variable syntax to ensure loadAuthValues is called
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-medium',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -517,6 +521,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -599,17 +604,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user456' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-medium',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-medium',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/image.png',
|
||||
originalname: 'image.png',
|
||||
|
|
@ -618,6 +622,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -698,17 +703,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${CUSTOM_API_KEY}',
|
||||
baseURL: '${CUSTOM_BASEURL}',
|
||||
mistralModel: '${CUSTOM_MODEL}',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${CUSTOM_API_KEY}',
|
||||
baseURL: '${CUSTOM_BASEURL}',
|
||||
mistralModel: '${CUSTOM_MODEL}',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
// Set environment variable for model
|
||||
process.env.CUSTOM_MODEL = 'mistral-large';
|
||||
|
||||
|
|
@ -720,6 +724,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -790,18 +795,17 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
// Use environment variable syntax to ensure loadAuthValues is called
|
||||
apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name
|
||||
baseURL: '${OCR_BASEURL}', // Using valid env var format
|
||||
mistralModel: 'mistral-ocr-latest', // Plain string value
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
// Use environment variable syntax to ensure loadAuthValues is called
|
||||
apiKey: '${INVALID_FORMAT}', // Using valid env var format but with an invalid name
|
||||
baseURL: '${OCR_BASEURL}', // Using valid env var format
|
||||
mistralModel: 'mistral-ocr-latest', // Plain string value
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -810,6 +814,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -845,16 +850,15 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: 'OCR_API_KEY',
|
||||
baseURL: 'OCR_BASEURL',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: 'OCR_API_KEY',
|
||||
baseURL: 'OCR_BASEURL',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -864,6 +868,7 @@ describe('MistralOCR Service', () => {
|
|||
await expect(
|
||||
uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
}),
|
||||
|
|
@ -931,17 +936,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: 'OCR_API_KEY',
|
||||
baseURL: 'OCR_BASEURL',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: 'OCR_API_KEY',
|
||||
baseURL: 'OCR_BASEURL',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'single-page.pdf',
|
||||
|
|
@ -950,6 +954,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1019,18 +1024,17 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
// Direct values that should be used as-is, without variable substitution
|
||||
apiKey: 'actual-api-key-value',
|
||||
baseURL: 'https://direct-api-url.mistral.ai/v1',
|
||||
mistralModel: 'mistral-direct-model',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
// Direct values that should be used as-is, without variable substitution
|
||||
apiKey: 'actual-api-key-value',
|
||||
baseURL: 'https://direct-api-url.mistral.ai/v1',
|
||||
mistralModel: 'mistral-direct-model',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'direct-values.pdf',
|
||||
|
|
@ -1039,6 +1043,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1133,18 +1138,17 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
// Empty string values - should fall back to defaults
|
||||
apiKey: '',
|
||||
baseURL: '',
|
||||
mistralModel: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
// Empty string values - should fall back to defaults
|
||||
apiKey: '',
|
||||
baseURL: '',
|
||||
mistralModel: '',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'empty-config.pdf',
|
||||
|
|
@ -1153,6 +1157,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1276,17 +1281,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
|
||||
baseURL: 'https://endpoint.models.ai.azure.com/v1',
|
||||
mistralModel: 'mistral-ocr-2503',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
|
||||
baseURL: 'https://endpoint.models.ai.azure.com/v1',
|
||||
mistralModel: 'mistral-ocr-2503',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1295,6 +1299,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1360,17 +1365,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user456' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: 'hardcoded-api-key-12345',
|
||||
baseURL: '${CUSTOM_OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: 'hardcoded-api-key-12345',
|
||||
baseURL: '${CUSTOM_OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1379,6 +1383,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1484,17 +1489,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1503,6 +1507,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1553,17 +1558,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1573,6 +1577,7 @@ describe('MistralOCR Service', () => {
|
|||
await expect(
|
||||
uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
}),
|
||||
|
|
@ -1641,17 +1646,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1661,6 +1665,7 @@ describe('MistralOCR Service', () => {
|
|||
// Should not throw even if delete fails
|
||||
const result = await uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1701,17 +1706,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1721,6 +1725,7 @@ describe('MistralOCR Service', () => {
|
|||
await expect(
|
||||
uploadMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
}),
|
||||
|
|
@ -1775,17 +1780,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${OCR_API_KEY}',
|
||||
baseURL: '${OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/azure-file.pdf',
|
||||
originalname: 'azure-document.pdf',
|
||||
|
|
@ -1794,6 +1798,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const result = await uploadAzureMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1851,17 +1856,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user123' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
|
||||
baseURL: 'https://endpoint.models.ai.azure.com/v1',
|
||||
mistralModel: 'mistral-ocr-2503',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: '${AZURE_MISTRAL_OCR_API_KEY}',
|
||||
baseURL: 'https://endpoint.models.ai.azure.com/v1',
|
||||
mistralModel: 'mistral-ocr-2503',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1870,6 +1874,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadAzureMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
@ -1915,17 +1920,16 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
const req = {
|
||||
user: { id: 'user456' },
|
||||
app: {
|
||||
locals: {
|
||||
ocr: {
|
||||
apiKey: 'hardcoded-api-key-12345',
|
||||
baseURL: '${CUSTOM_OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ExpressRequest;
|
||||
|
||||
const appConfig = {
|
||||
ocr: {
|
||||
apiKey: 'hardcoded-api-key-12345',
|
||||
baseURL: '${CUSTOM_OCR_BASEURL}',
|
||||
mistralModel: 'mistral-ocr-latest',
|
||||
},
|
||||
} as AppConfig;
|
||||
|
||||
const file = {
|
||||
path: '/tmp/upload/file.pdf',
|
||||
originalname: 'document.pdf',
|
||||
|
|
@ -1934,6 +1938,7 @@ describe('MistralOCR Service', () => {
|
|||
|
||||
await uploadAzureMistralOCR({
|
||||
req,
|
||||
appConfig,
|
||||
file,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
MistralOCRUploadResult,
|
||||
MistralOCRError,
|
||||
OCRResultPage,
|
||||
AppConfig,
|
||||
OCRResult,
|
||||
OCRImage,
|
||||
} from '~/types';
|
||||
|
|
@ -42,14 +43,10 @@ interface GoogleServiceAccount {
|
|||
|
||||
/** Helper type for OCR request context */
|
||||
interface OCRContext {
|
||||
req: Pick<ServerRequest, 'user' | 'app'> & {
|
||||
req: Pick<ServerRequest, 'user'> & {
|
||||
user?: { id: string };
|
||||
app: {
|
||||
locals?: {
|
||||
ocr?: TCustomConfig['ocr'];
|
||||
};
|
||||
};
|
||||
};
|
||||
appConfig: AppConfig;
|
||||
file: Express.Multer.File;
|
||||
loadAuthValues: (params: {
|
||||
userId: string;
|
||||
|
|
@ -241,7 +238,7 @@ async function resolveConfigValue(
|
|||
* Loads authentication configuration from OCR config
|
||||
*/
|
||||
async function loadAuthConfig(context: OCRContext): Promise<AuthConfig> {
|
||||
const ocrConfig = context.req.app.locals?.ocr;
|
||||
const ocrConfig = context.appConfig?.ocr;
|
||||
const apiKeyConfig = ocrConfig?.apiKey || '';
|
||||
const baseURLConfig = ocrConfig?.baseURL || '';
|
||||
|
||||
|
|
@ -357,6 +354,7 @@ function createOCRError(error: unknown, baseMessage: string): Error {
|
|||
* @param params - The params object.
|
||||
* @param params.req - The request object from Express. It should have a `user` property with an `id`
|
||||
* representing the user
|
||||
* @param params.appConfig - Application configuration object
|
||||
* @param params.file - The file object, which is part of the request. The file object should
|
||||
* have a `mimetype` property that tells us the file type
|
||||
* @param params.loadAuthValues - Function to load authentication values
|
||||
|
|
@ -372,7 +370,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
|
|||
const authConfig = await loadAuthConfig(context);
|
||||
apiKey = authConfig.apiKey;
|
||||
baseURL = authConfig.baseURL;
|
||||
const model = getModelConfig(context.req.app.locals?.ocr);
|
||||
const model = getModelConfig(context.appConfig?.ocr);
|
||||
|
||||
const mistralFile = await uploadDocumentToMistral({
|
||||
filePath: context.file.path,
|
||||
|
|
@ -430,6 +428,7 @@ export const uploadMistralOCR = async (context: OCRContext): Promise<MistralOCRU
|
|||
* @param params - The params object.
|
||||
* @param params.req - The request object from Express. It should have a `user` property with an `id`
|
||||
* representing the user
|
||||
* @param params.appConfig - Application configuration object
|
||||
* @param params.file - The file object, which is part of the request. The file object should
|
||||
* have a `mimetype` property that tells us the file type
|
||||
* @param params.loadAuthValues - Function to load authentication values
|
||||
|
|
@ -441,7 +440,7 @@ export const uploadAzureMistralOCR = async (
|
|||
): Promise<MistralOCRUploadResult> => {
|
||||
try {
|
||||
const { apiKey, baseURL } = await loadAuthConfig(context);
|
||||
const model = getModelConfig(context.req.app.locals?.ocr);
|
||||
const model = getModelConfig(context.appConfig?.ocr);
|
||||
|
||||
const buffer = fs.readFileSync(context.file.path);
|
||||
const base64 = buffer.toString('base64');
|
||||
|
|
@ -644,6 +643,7 @@ async function performGoogleVertexOCR({
|
|||
* @param params - The params object.
|
||||
* @param params.req - The request object from Express. It should have a `user` property with an `id`
|
||||
* representing the user
|
||||
* @param params.appConfig - Application configuration object
|
||||
* @param params.file - The file object, which is part of the request. The file object should
|
||||
* have a `mimetype` property that tells us the file type
|
||||
* @param params.loadAuthValues - Function to load authentication values
|
||||
|
|
@ -655,7 +655,7 @@ export const uploadGoogleVertexMistralOCR = async (
|
|||
): Promise<MistralOCRUploadResult> => {
|
||||
try {
|
||||
const { serviceAccount, accessToken } = await loadGoogleAuthConfig();
|
||||
const model = getModelConfig(context.req.app.locals?.ocr);
|
||||
const model = getModelConfig(context.appConfig?.ocr);
|
||||
|
||||
const buffer = fs.readFileSync(context.file.path);
|
||||
const base64 = buffer.toString('base64');
|
||||
|
|
|
|||
40
packages/api/src/files/parse.ts
Normal file
40
packages/api/src/files/parse.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import path from 'path';
|
||||
import { URL } from 'url';
|
||||
|
||||
const imageExtensionRegex = /\.(jpg|jpeg|png|gif|bmp|tiff|svg|webp)$/i;
|
||||
|
||||
/**
|
||||
* Extracts the image basename from a given URL.
|
||||
*
|
||||
* @param urlString - The URL string from which the image basename is to be extracted.
|
||||
* @returns The basename of the image file from the URL.
|
||||
* Returns an empty string if the URL does not contain a valid image basename.
|
||||
*/
|
||||
export function getImageBasename(urlString: string) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const basename = path.basename(url.pathname);
|
||||
|
||||
return imageExtensionRegex.test(basename) ? basename : '';
|
||||
} catch {
|
||||
// If URL parsing fails, return an empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the basename of a file from a given URL.
|
||||
*
|
||||
* @param urlString - The URL string from which the file basename is to be extracted.
|
||||
* @returns The basename of the file from the URL.
|
||||
* Returns an empty string if the URL parsing fails.
|
||||
*/
|
||||
export function getFileBasename(urlString: string) {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return path.basename(url.pathname);
|
||||
} catch {
|
||||
// If URL parsing fails, return an empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './app';
|
||||
/* MCP */
|
||||
export * from './mcp/MCPManager';
|
||||
export * from './mcp/connection';
|
||||
|
|
@ -33,3 +34,4 @@ export * from './web';
|
|||
/* types */
|
||||
export type * from './mcp/types';
|
||||
export type * from './flow/types';
|
||||
export type * from './types';
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ export class MCPManager extends UserConnectionManager {
|
|||
return this.serversRegistry.oauthServers!;
|
||||
}
|
||||
|
||||
/** Get all servers */
|
||||
public getAllServers(): t.MCPServers | null {
|
||||
return this.serversRegistry.rawConfigs!;
|
||||
}
|
||||
|
||||
/** Returns all available tool functions from app-level connections */
|
||||
public getAppToolFunctions(): t.LCAvailableTools | null {
|
||||
return this.serversRegistry.toolFunctions!;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import mongoose from 'mongoose';
|
|||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { logger, balanceSchema } from '@librechat/data-schemas';
|
||||
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
|
||||
import type { IBalance, BalanceConfig } from '@librechat/data-schemas';
|
||||
import type { IBalance } from '@librechat/data-schemas';
|
||||
import { createSetBalanceConfig } from './balance';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
|
|
@ -48,23 +48,22 @@ describe('createSetBalanceConfig', () => {
|
|||
});
|
||||
|
||||
const mockNext: NextFunction = jest.fn();
|
||||
|
||||
const defaultBalanceConfig: BalanceConfig = {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
};
|
||||
|
||||
describe('Basic Functionality', () => {
|
||||
test('should create balance record for new user with start balance', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -73,7 +72,7 @@ describe('createSetBalanceConfig', () => {
|
|||
|
||||
await middleware(req as ServerRequest, res as ServerResponse, mockNext);
|
||||
|
||||
expect(getBalanceConfig).toHaveBeenCalled();
|
||||
expect(getAppConfig).toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
|
||||
const balanceRecord = await Balance.findOne({ user: userId });
|
||||
|
|
@ -88,10 +87,14 @@ describe('createSetBalanceConfig', () => {
|
|||
|
||||
test('should skip if balance config is not enabled', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({ enabled: false });
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -108,13 +111,15 @@ describe('createSetBalanceConfig', () => {
|
|||
|
||||
test('should skip if startBalance is null', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: null,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: null,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -131,10 +136,19 @@ describe('createSetBalanceConfig', () => {
|
|||
|
||||
test('should handle user._id as string', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -151,10 +165,19 @@ describe('createSetBalanceConfig', () => {
|
|||
});
|
||||
|
||||
test('should skip if user is not present in request', async () => {
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -164,7 +187,7 @@ describe('createSetBalanceConfig', () => {
|
|||
await middleware(req, res as ServerResponse, mockNext);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
expect(getBalanceConfig).toHaveBeenCalled();
|
||||
expect(getAppConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -183,17 +206,19 @@ describe('createSetBalanceConfig', () => {
|
|||
// Remove lastRefill to simulate existing user without it
|
||||
await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } });
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -233,17 +258,19 @@ describe('createSetBalanceConfig', () => {
|
|||
lastRefill: existingLastRefill,
|
||||
});
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -275,17 +302,19 @@ describe('createSetBalanceConfig', () => {
|
|||
// Remove lastRefill to simulate the edge case
|
||||
await Balance.updateOne({ _id: doc._id }, { $unset: { lastRefill: 1 } });
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -306,14 +335,17 @@ describe('createSetBalanceConfig', () => {
|
|||
test('should not set lastRefill when auto-refill is disabled', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: false,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -347,17 +379,19 @@ describe('createSetBalanceConfig', () => {
|
|||
refillAmount: 100,
|
||||
});
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -389,10 +423,19 @@ describe('createSetBalanceConfig', () => {
|
|||
lastRefill: lastRefillTime,
|
||||
});
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -417,13 +460,16 @@ describe('createSetBalanceConfig', () => {
|
|||
tokenCredits: null,
|
||||
});
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 2000,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
|
||||
startBalance: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -440,7 +486,16 @@ describe('createSetBalanceConfig', () => {
|
|||
describe('Error Handling', () => {
|
||||
test('should handle database errors gracefully', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
const dbError = new Error('Database error');
|
||||
|
||||
// Mock Balance.findOne to throw an error
|
||||
|
|
@ -451,7 +506,7 @@ describe('createSetBalanceConfig', () => {
|
|||
}) as unknown as mongoose.Model<IBalance>['findOne']);
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -464,13 +519,13 @@ describe('createSetBalanceConfig', () => {
|
|||
expect(mockNext).toHaveBeenCalledWith(dbError);
|
||||
});
|
||||
|
||||
test('should handle getBalanceConfig errors', async () => {
|
||||
test('should handle getAppConfig errors', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const configError = new Error('Config error');
|
||||
const getBalanceConfig = jest.fn().mockRejectedValue(configError);
|
||||
const getAppConfig = jest.fn().mockRejectedValue(configError);
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -487,17 +542,20 @@ describe('createSetBalanceConfig', () => {
|
|||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
// Missing required auto-refill fields
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: null, // Invalid
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: null, // Invalid
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -519,10 +577,19 @@ describe('createSetBalanceConfig', () => {
|
|||
describe('Concurrent Updates', () => {
|
||||
test('should handle concurrent middleware calls for same user', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue(defaultBalanceConfig);
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 30,
|
||||
refillIntervalUnit: 'days',
|
||||
refillAmount: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
@ -554,17 +621,20 @@ describe('createSetBalanceConfig', () => {
|
|||
async (unit) => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const getBalanceConfig = jest.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 10,
|
||||
refillIntervalUnit: unit,
|
||||
refillAmount: 100,
|
||||
const getAppConfig = jest.fn().mockResolvedValue({
|
||||
balance: {
|
||||
enabled: true,
|
||||
|
||||
startBalance: 1000,
|
||||
autoRefillEnabled: true,
|
||||
refillIntervalValue: 10,
|
||||
refillIntervalUnit: unit,
|
||||
refillAmount: 100,
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { logger } from '@librechat/data-schemas';
|
|||
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
|
||||
import type { IBalance, IUser, BalanceConfig, ObjectId } from '@librechat/data-schemas';
|
||||
import type { Model } from 'mongoose';
|
||||
import type { BalanceUpdateFields } from '~/types';
|
||||
import type { AppConfig, BalanceUpdateFields } from '~/types';
|
||||
import { getBalanceConfig } from '~/app/config';
|
||||
|
||||
export interface BalanceMiddlewareOptions {
|
||||
getBalanceConfig: () => Promise<BalanceConfig | null>;
|
||||
getAppConfig: (options?: { role?: string; refresh?: boolean }) => Promise<AppConfig>;
|
||||
Balance: Model<IBalance>;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ function buildUpdateFields(
|
|||
* @returns Express middleware function
|
||||
*/
|
||||
export function createSetBalanceConfig({
|
||||
getBalanceConfig,
|
||||
getAppConfig,
|
||||
Balance,
|
||||
}: BalanceMiddlewareOptions): (
|
||||
req: ServerRequest,
|
||||
|
|
@ -82,7 +83,9 @@ export function createSetBalanceConfig({
|
|||
) => Promise<void> {
|
||||
return async (req: ServerRequest, res: ServerResponse, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const balanceConfig = await getBalanceConfig();
|
||||
const user = req.user as IUser & { _id: string | ObjectId };
|
||||
const appConfig = await getAppConfig({ role: user?.role });
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
if (!balanceConfig?.enabled) {
|
||||
return next();
|
||||
}
|
||||
|
|
@ -90,7 +93,6 @@ export function createSetBalanceConfig({
|
|||
return next();
|
||||
}
|
||||
|
||||
const user = req.user as IUser & { _id: string | ObjectId };
|
||||
if (!user || !user._id) {
|
||||
return next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { TPlugin, FunctionTool, TCustomConfig } from 'librechat-data-provider';
|
||||
import type { TPlugin, FunctionTool } from 'librechat-data-provider';
|
||||
import type { MCPManager } from '~/mcp/MCPManager';
|
||||
import {
|
||||
convertMCPToolsToPlugins,
|
||||
filterUniquePlugins,
|
||||
|
|
@ -277,19 +278,18 @@ describe('format.ts helper functions', () => {
|
|||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const customConfig: Partial<TCustomConfig> = {
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command: 'test',
|
||||
args: [],
|
||||
iconPath: '/path/to/icon.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
iconPath: '/path/to/icon.png',
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBe('/path/to/icon.png');
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle customUserVars in server config', () => {
|
||||
|
|
@ -300,26 +300,25 @@ describe('format.ts helper functions', () => {
|
|||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const customConfig: Partial<TCustomConfig> = {
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
SECRET: { title: 'Secret', description: 'Your secret' },
|
||||
},
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
SECRET: { title: 'Secret', description: 'Your secret' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toHaveLength(2);
|
||||
expect(result![0].authConfig).toEqual([
|
||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
||||
{ authField: 'SECRET', label: 'Secret', description: 'Your secret' },
|
||||
]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should use key as label when title is missing in customUserVars', () => {
|
||||
|
|
@ -330,23 +329,22 @@ describe('format.ts helper functions', () => {
|
|||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const customConfig: Partial<TCustomConfig> = {
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
},
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {
|
||||
API_KEY: { title: 'API Key', description: 'Your API key' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toEqual([
|
||||
{ authField: 'API_KEY', label: 'API Key', description: 'Your API key' },
|
||||
]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle empty customUserVars', () => {
|
||||
|
|
@ -357,19 +355,51 @@ describe('format.ts helper functions', () => {
|
|||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const customConfig: Partial<TCustomConfig> = {
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue({
|
||||
command: 'test',
|
||||
args: [],
|
||||
customUserVars: {},
|
||||
}),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, customConfig });
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
|
||||
it('should handle missing mcpManager', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBeUndefined();
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle when getRawConfig returns undefined', () => {
|
||||
const functionTools: Record<string, FunctionTool> = {
|
||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||
type: 'function',
|
||||
function: { name: 'tool1', description: 'Tool 1' },
|
||||
} as FunctionTool,
|
||||
};
|
||||
|
||||
const mockMcpManager = {
|
||||
getRawConfig: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as MCPManager;
|
||||
|
||||
const result = convertMCPToolsToPlugins({ functionTools, mcpManager: mockMcpManager });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].icon).toBeUndefined();
|
||||
expect(result![0].authConfig).toEqual([]);
|
||||
expect(mockMcpManager.getRawConfig).toHaveBeenCalledWith('server1');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { TCustomConfig, TPlugin } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { MCPManager } from '~/mcp/MCPManager';
|
||||
import { LCAvailableTools, LCFunctionTool } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
|
|
@ -58,11 +59,11 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
|
|||
export function convertMCPToolToPlugin({
|
||||
toolKey,
|
||||
toolData,
|
||||
customConfig,
|
||||
mcpManager,
|
||||
}: {
|
||||
toolKey: string;
|
||||
toolData: LCFunctionTool;
|
||||
customConfig?: Partial<TCustomConfig> | null;
|
||||
mcpManager?: MCPManager;
|
||||
}): TPlugin | undefined {
|
||||
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||
return;
|
||||
|
|
@ -72,7 +73,7 @@ export function convertMCPToolToPlugin({
|
|||
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
|
||||
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||
const serverConfig = mcpManager?.getRawConfig(serverName);
|
||||
|
||||
const plugin: TPlugin = {
|
||||
/** Tool name without server suffix */
|
||||
|
|
@ -111,10 +112,10 @@ export function convertMCPToolToPlugin({
|
|||
*/
|
||||
export function convertMCPToolsToPlugins({
|
||||
functionTools,
|
||||
customConfig,
|
||||
mcpManager,
|
||||
}: {
|
||||
functionTools?: LCAvailableTools;
|
||||
customConfig?: Partial<TCustomConfig> | null;
|
||||
mcpManager?: MCPManager;
|
||||
}): TPlugin[] | undefined {
|
||||
if (!functionTools || typeof functionTools !== 'object') {
|
||||
return;
|
||||
|
|
@ -122,7 +123,7 @@ export function convertMCPToolsToPlugins({
|
|||
|
||||
const plugins: TPlugin[] = [];
|
||||
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||
const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig });
|
||||
const plugin = convertMCPToolToPlugin({ toolKey, toolData, mcpManager });
|
||||
if (plugin) {
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './format';
|
||||
export * from './toolkits';
|
||||
|
|
|
|||
2
packages/api/src/tools/toolkits/index.ts
Normal file
2
packages/api/src/tools/toolkits/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './oai';
|
||||
export * from './yt';
|
||||
153
packages/api/src/tools/toolkits/oai.ts
Normal file
153
packages/api/src/tools/toolkits/oai.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/** Default descriptions for image generation tool */
|
||||
const DEFAULT_IMAGE_GEN_DESCRIPTION =
|
||||
`Generates high-quality, original images based solely on text, not using any uploaded reference images.
|
||||
|
||||
When to use \`image_gen_oai\`:
|
||||
- To create entirely new images from detailed text descriptions that do NOT reference any image files.
|
||||
|
||||
When NOT to use \`image_gen_oai\`:
|
||||
- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead.
|
||||
|
||||
Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.` as const;
|
||||
|
||||
const getImageGenDescription = () => {
|
||||
return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION;
|
||||
};
|
||||
|
||||
/** Default prompt descriptions */
|
||||
const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail.
|
||||
Be highly specific—break your idea into layers:
|
||||
(1) main concept and subject,
|
||||
(2) composition and position,
|
||||
(3) lighting and mood,
|
||||
(4) style, medium, or camera details,
|
||||
(5) important features (age, expression, clothing, etc.),
|
||||
(6) background.
|
||||
Use positive, descriptive language and specify what should be included, not what to avoid.
|
||||
List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour").
|
||||
Do not reference any uploaded images—use for new image creation from text only.` as const;
|
||||
|
||||
const getImageGenPromptDescription = () => {
|
||||
return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION;
|
||||
};
|
||||
|
||||
/** Default description for image editing tool */
|
||||
const DEFAULT_IMAGE_EDIT_DESCRIPTION =
|
||||
`Generates high-quality, original images based on text and one or more uploaded/referenced images.
|
||||
|
||||
When to use \`image_edit_oai\`:
|
||||
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
|
||||
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
||||
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
|
||||
- Any current or existing images are to be used as visual guides.
|
||||
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
|
||||
|
||||
When NOT to use \`image_edit_oai\`:
|
||||
- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead.
|
||||
|
||||
Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`.
|
||||
`.trim();
|
||||
|
||||
const getImageEditDescription = () => {
|
||||
return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s).
|
||||
Be highly specific—break your request into layers:
|
||||
(1) main concept or transformation,
|
||||
(2) specific edits/replacements or composition guidance,
|
||||
(3) desired style, mood, or technique,
|
||||
(4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.).
|
||||
Use positive, descriptive language and clarify what should be included or changed, not what to avoid.
|
||||
Always base this prompt on the most recently uploaded reference images.`;
|
||||
|
||||
const getImageEditPromptDescription = () => {
|
||||
return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION;
|
||||
};
|
||||
|
||||
export const oaiToolkit = {
|
||||
image_gen_oai: {
|
||||
name: 'image_gen_oai' as const,
|
||||
description: getImageGenDescription(),
|
||||
schema: z.object({
|
||||
prompt: z.string().max(32000).describe(getImageGenPromptDescription()),
|
||||
background: z
|
||||
.enum(['transparent', 'opaque', 'auto'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.',
|
||||
),
|
||||
/*
|
||||
n: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe('The number of images to generate. Must be between 1 and 10.'),
|
||||
output_compression: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'),
|
||||
*/
|
||||
quality: z
|
||||
.enum(['auto', 'high', 'medium', 'low'])
|
||||
.optional()
|
||||
.describe('The quality of the image. One of auto (default), high, medium, or low.'),
|
||||
size: z
|
||||
.enum(['auto', '1024x1024', '1536x1024', '1024x1536'])
|
||||
.optional()
|
||||
.describe(
|
||||
'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).',
|
||||
),
|
||||
}),
|
||||
responseFormat: 'content_and_artifact' as const,
|
||||
} as const,
|
||||
image_edit_oai: {
|
||||
name: 'image_edit_oai' as const,
|
||||
description: getImageEditDescription(),
|
||||
schema: z.object({
|
||||
image_ids: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe(
|
||||
`
|
||||
IDs (image ID strings) of previously generated or uploaded images that should guide the edit.
|
||||
|
||||
Guidelines:
|
||||
- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them).
|
||||
- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context.
|
||||
- If no earlier image is relevant, omit the field entirely.
|
||||
`.trim(),
|
||||
),
|
||||
prompt: z.string().max(32000).describe(getImageEditPromptDescription()),
|
||||
/*
|
||||
n: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'),
|
||||
*/
|
||||
quality: z
|
||||
.enum(['auto', 'high', 'medium', 'low'])
|
||||
.optional()
|
||||
.describe(
|
||||
'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.',
|
||||
),
|
||||
size: z
|
||||
.enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512'])
|
||||
.optional()
|
||||
.describe(
|
||||
'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.',
|
||||
),
|
||||
}),
|
||||
responseFormat: 'content_and_artifact' as const,
|
||||
},
|
||||
} as const;
|
||||
61
packages/api/src/tools/toolkits/yt.ts
Normal file
61
packages/api/src/tools/toolkits/yt.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { z } from 'zod';
|
||||
export const ytToolkit = {
|
||||
youtube_search: {
|
||||
name: 'youtube_search' as const,
|
||||
description: `Search for YouTube videos by keyword or phrase.
|
||||
- Required: query (search terms to find videos)
|
||||
- Optional: maxResults (number of videos to return, 1-50, default: 5)
|
||||
- Returns: List of videos with titles, descriptions, and URLs
|
||||
- Use for: Finding specific videos, exploring content, research
|
||||
Example: query="cooking pasta tutorials" maxResults=3` as const,
|
||||
schema: z.object({
|
||||
query: z.string().describe('Search query terms'),
|
||||
maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'),
|
||||
}),
|
||||
},
|
||||
youtube_info: {
|
||||
name: 'youtube_info' as const,
|
||||
description: `Get detailed metadata and statistics for a specific YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Returns: Video title, description, view count, like count, comment count
|
||||
- Use for: Getting video metrics and basic metadata
|
||||
- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS
|
||||
- Accepts both full URLs and video IDs
|
||||
Example: url="https://youtube.com/watch?v=abc123" or url="abc123"` as const,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
}),
|
||||
} as const,
|
||||
youtube_comments: {
|
||||
name: 'youtube_comments',
|
||||
description: `Retrieve top-level comments from a YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Optional: maxResults (number of comments, 1-50, default: 10)
|
||||
- Returns: Comment text, author names, like counts
|
||||
- Use for: Sentiment analysis, audience feedback, engagement review
|
||||
Example: url="abc123" maxResults=20`,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
maxResults: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Number of comments to retrieve'),
|
||||
}),
|
||||
} as const,
|
||||
youtube_transcript: {
|
||||
name: 'youtube_transcript',
|
||||
description: `Fetch and parse the transcript/captions of a YouTube video.
|
||||
- Required: url (full YouTube URL or video ID)
|
||||
- Returns: Full video transcript as plain text
|
||||
- Use for: Content analysis, summarization, translation reference
|
||||
- This is the "Go-to" tool for analyzing actual video content
|
||||
- Attempts to fetch English first, then German, then any available language
|
||||
Example: url="https://youtube.com/watch?v=abc123"`,
|
||||
schema: z.object({
|
||||
url: z.string().describe('YouTube video URL or ID'),
|
||||
}),
|
||||
} as const,
|
||||
} as const;
|
||||
90
packages/api/src/types/config.ts
Normal file
90
packages/api/src/types/config.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type {
|
||||
TEndpoint,
|
||||
FileSources,
|
||||
TAzureConfig,
|
||||
TCustomConfig,
|
||||
TMemoryConfig,
|
||||
EModelEndpoint,
|
||||
TAgentsEndpoint,
|
||||
TCustomEndpoints,
|
||||
TAssistantEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FunctionTool } from './tools';
|
||||
|
||||
/**
|
||||
* Application configuration object
|
||||
* Based on the configuration defined in api/server/services/Config/getAppConfig.js
|
||||
*/
|
||||
export interface AppConfig {
|
||||
/** The main custom configuration */
|
||||
config: TCustomConfig;
|
||||
/** OCR configuration */
|
||||
ocr?: TCustomConfig['ocr'];
|
||||
/** File paths configuration */
|
||||
paths: {
|
||||
uploads: string;
|
||||
imageOutput: string;
|
||||
publicPath: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Memory configuration */
|
||||
memory?: TMemoryConfig;
|
||||
/** Web search configuration */
|
||||
webSearch?: TCustomConfig['webSearch'];
|
||||
/** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */
|
||||
fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob;
|
||||
/** File strategies configuration */
|
||||
fileStrategies: TCustomConfig['fileStrategies'];
|
||||
/** Registration configurations */
|
||||
registration?: TCustomConfig['registration'];
|
||||
/** Actions configurations */
|
||||
actions?: TCustomConfig['actions'];
|
||||
/** Admin-filtered tools */
|
||||
filteredTools?: string[];
|
||||
/** Admin-included tools */
|
||||
includedTools?: string[];
|
||||
/** Image output type configuration */
|
||||
imageOutputType: string;
|
||||
/** Interface configuration */
|
||||
interfaceConfig?: TCustomConfig['interface'];
|
||||
/** Turnstile configuration */
|
||||
turnstileConfig?: TCustomConfig['turnstile'];
|
||||
/** Balance configuration */
|
||||
balance?: TCustomConfig['balance'];
|
||||
/** Speech configuration */
|
||||
speech?: TCustomConfig['speech'];
|
||||
/** MCP server configuration */
|
||||
mcpConfig?: TCustomConfig['mcpServers'] | null;
|
||||
/** File configuration */
|
||||
fileConfig?: TCustomConfig['fileConfig'];
|
||||
/** Secure image links configuration */
|
||||
secureImageLinks?: TCustomConfig['secureImageLinks'];
|
||||
/** Processed model specifications */
|
||||
modelSpecs?: TCustomConfig['modelSpecs'];
|
||||
/** Available tools */
|
||||
availableTools?: Record<string, FunctionTool>;
|
||||
endpoints?: {
|
||||
/** OpenAI endpoint configuration */
|
||||
openAI?: TEndpoint;
|
||||
/** Google endpoint configuration */
|
||||
google?: TEndpoint;
|
||||
/** Bedrock endpoint configuration */
|
||||
bedrock?: TEndpoint;
|
||||
/** Anthropic endpoint configuration */
|
||||
anthropic?: TEndpoint;
|
||||
/** GPT plugins endpoint configuration */
|
||||
gptPlugins?: TEndpoint;
|
||||
/** Azure OpenAI endpoint configuration */
|
||||
azureOpenAI?: TAzureConfig;
|
||||
/** Assistants endpoint configuration */
|
||||
assistants?: TAssistantEndpoint;
|
||||
/** Azure assistants endpoint configuration */
|
||||
azureAssistants?: TAssistantEndpoint;
|
||||
/** Agents endpoint configuration */
|
||||
[EModelEndpoint.agents]?: TAgentsEndpoint;
|
||||
/** Custom endpoints configuration */
|
||||
[EModelEndpoint.custom]?: TCustomEndpoints;
|
||||
/** Global endpoint configuration */
|
||||
all?: TEndpoint;
|
||||
};
|
||||
}
|
||||
3
packages/api/src/types/endpoints.ts
Normal file
3
packages/api/src/types/endpoints.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { TConfig } from 'librechat-data-provider';
|
||||
|
||||
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TConfig, 'order'> }>;
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
export * from './config';
|
||||
export * from './azure';
|
||||
export * from './balance';
|
||||
export * from './endpoints';
|
||||
export * from './events';
|
||||
export * from './error';
|
||||
export * from './google';
|
||||
|
|
@ -8,4 +10,5 @@ export * from './mistral';
|
|||
export * from './openai';
|
||||
export * from './prompts';
|
||||
export * from './run';
|
||||
export * from './tools';
|
||||
export * from './zod';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { TEndpointOption, TAzureConfig, TEndpoint } from 'librechat-data-pr
|
|||
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
|
||||
import type { OpenAIClientOptions, Providers } from '@librechat/agents';
|
||||
import type { AzureOptions } from './azure';
|
||||
import type { AppConfig } from './config';
|
||||
|
||||
export type OpenAIParameters = z.infer<typeof openAISchema>;
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export type CheckUserKeyExpiryFunction = (expiresAt: string, endpoint: string) =
|
|||
*/
|
||||
export interface InitializeOpenAIOptionsParams {
|
||||
req: RequestData;
|
||||
appConfig: AppConfig;
|
||||
overrideModel?: string;
|
||||
overrideEndpoint?: string;
|
||||
endpointOption: Partial<TEndpointOption>;
|
||||
|
|
|
|||
10
packages/api/src/types/tools.ts
Normal file
10
packages/api/src/types/tools.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { JsonSchemaType } from './zod';
|
||||
|
||||
export interface FunctionTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
description: string;
|
||||
name: string;
|
||||
parameters: JsonSchemaType;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import { AuthType } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Checks if the given value is truthy by being either the boolean `true` or a string
|
||||
* that case-insensitively matches 'true'.
|
||||
|
|
@ -31,7 +34,7 @@ export function isEnabled(value?: string | boolean | null | undefined): boolean
|
|||
* @param value - The value to check.
|
||||
* @returns - Returns true if the value is 'user_provided', otherwise false.
|
||||
*/
|
||||
export const isUserProvided = (value?: string): boolean => value === 'user_provided';
|
||||
export const isUserProvided = (value?: string): boolean => value === AuthType.USER_PROVIDED;
|
||||
|
||||
/**
|
||||
* @param values
|
||||
|
|
@ -46,3 +49,11 @@ export function optionalChainWithEmptyCheck(
|
|||
}
|
||||
return values[values.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the endpoint name to system-expected value.
|
||||
* @param name
|
||||
*/
|
||||
export function normalizeEndpointName(name = ''): string {
|
||||
return name.toLowerCase() === Providers.OLLAMA ? Providers.OLLAMA : name;
|
||||
}
|
||||
|
|
|
|||
16
packages/api/src/utils/email.ts
Normal file
16
packages/api/src/utils/email.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Check if email configuration is set
|
||||
* @returns Returns `true` if either Mailgun or SMTP is properly configured
|
||||
*/
|
||||
export function checkEmailConfig(): boolean {
|
||||
const hasMailgunConfig =
|
||||
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
|
||||
|
||||
const hasSMTPConfig =
|
||||
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
||||
!!process.env.EMAIL_USERNAME &&
|
||||
!!process.env.EMAIL_PASSWORD &&
|
||||
!!process.env.EMAIL_FROM;
|
||||
|
||||
return hasMailgunConfig || hasSMTPConfig;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './axios';
|
||||
export * from './azure';
|
||||
export * from './common';
|
||||
export * from './email';
|
||||
export * from './env';
|
||||
export * from './events';
|
||||
export * from './files';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { AppConfig } from '~/types';
|
||||
import {
|
||||
createTempChatExpirationDate,
|
||||
getTempChatRetentionHours,
|
||||
DEFAULT_RETENTION_HOURS,
|
||||
MIN_RETENTION_HOURS,
|
||||
MAX_RETENTION_HOURS,
|
||||
DEFAULT_RETENTION_HOURS,
|
||||
getTempChatRetentionHours,
|
||||
createTempChatExpirationDate,
|
||||
} from './tempChatRetention';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
|
||||
describe('tempChatRetention', () => {
|
||||
const originalEnv = process.env;
|
||||
|
|
@ -33,43 +33,43 @@ describe('tempChatRetention', () => {
|
|||
});
|
||||
|
||||
it('should use config value when set', () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 12,
|
||||
},
|
||||
};
|
||||
const result = getTempChatRetentionHours(config);
|
||||
const result = getTempChatRetentionHours(config?.interfaceConfig);
|
||||
expect(result).toBe(12);
|
||||
});
|
||||
|
||||
it('should prioritize config over environment variable', () => {
|
||||
process.env.TEMP_CHAT_RETENTION_HOURS = '48';
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 12,
|
||||
},
|
||||
};
|
||||
const result = getTempChatRetentionHours(config);
|
||||
const result = getTempChatRetentionHours(config?.interfaceConfig);
|
||||
expect(result).toBe(12);
|
||||
});
|
||||
|
||||
it('should enforce minimum retention period', () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 0,
|
||||
},
|
||||
};
|
||||
const result = getTempChatRetentionHours(config);
|
||||
const result = getTempChatRetentionHours(config?.interfaceConfig);
|
||||
expect(result).toBe(MIN_RETENTION_HOURS);
|
||||
});
|
||||
|
||||
it('should enforce maximum retention period', () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 10000,
|
||||
},
|
||||
};
|
||||
const result = getTempChatRetentionHours(config);
|
||||
const result = getTempChatRetentionHours(config?.interfaceConfig);
|
||||
expect(result).toBe(MAX_RETENTION_HOURS);
|
||||
});
|
||||
|
||||
|
|
@ -80,12 +80,12 @@ describe('tempChatRetention', () => {
|
|||
});
|
||||
|
||||
it('should handle invalid config value', () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 'invalid' as unknown as number,
|
||||
},
|
||||
};
|
||||
const result = getTempChatRetentionHours(config);
|
||||
const result = getTempChatRetentionHours(config?.interfaceConfig);
|
||||
expect(result).toBe(DEFAULT_RETENTION_HOURS);
|
||||
});
|
||||
});
|
||||
|
|
@ -103,13 +103,13 @@ describe('tempChatRetention', () => {
|
|||
});
|
||||
|
||||
it('should create expiration date with custom retention period', () => {
|
||||
const config: Partial<TCustomConfig> = {
|
||||
interface: {
|
||||
const config: Partial<AppConfig> = {
|
||||
interfaceConfig: {
|
||||
temporaryChatRetention: 12,
|
||||
},
|
||||
};
|
||||
|
||||
const result = createTempChatExpirationDate(config);
|
||||
const result = createTempChatExpirationDate(config?.interfaceConfig);
|
||||
|
||||
const expectedDate = new Date();
|
||||
expectedDate.setHours(expectedDate.getHours() + 12);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { TCustomConfig } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '~/types';
|
||||
|
||||
/**
|
||||
* Default retention period for temporary chats in hours
|
||||
|
|
@ -18,10 +18,12 @@ export const MAX_RETENTION_HOURS = 8760;
|
|||
|
||||
/**
|
||||
* Gets the temporary chat retention period from environment variables or config
|
||||
* @param config - The custom configuration object
|
||||
* @param interfaceConfig - The custom configuration object
|
||||
* @returns The retention period in hours
|
||||
*/
|
||||
export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null): number {
|
||||
export function getTempChatRetentionHours(
|
||||
interfaceConfig?: AppConfig['interfaceConfig'] | null,
|
||||
): number {
|
||||
let retentionHours = DEFAULT_RETENTION_HOURS;
|
||||
|
||||
// Check environment variable first
|
||||
|
|
@ -37,8 +39,8 @@ export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null
|
|||
}
|
||||
|
||||
// Check config file (takes precedence over environment variable)
|
||||
if (config?.interface?.temporaryChatRetention !== undefined) {
|
||||
const configValue = config.interface.temporaryChatRetention;
|
||||
if (interfaceConfig?.temporaryChatRetention !== undefined) {
|
||||
const configValue = interfaceConfig.temporaryChatRetention;
|
||||
if (typeof configValue === 'number' && !isNaN(configValue)) {
|
||||
retentionHours = configValue;
|
||||
} else {
|
||||
|
|
@ -66,11 +68,11 @@ export function getTempChatRetentionHours(config?: Partial<TCustomConfig> | null
|
|||
|
||||
/**
|
||||
* Creates an expiration date for temporary chats
|
||||
* @param config - The custom configuration object
|
||||
* @param interfaceConfig - The custom configuration object
|
||||
* @returns The expiration date
|
||||
*/
|
||||
export function createTempChatExpirationDate(config?: Partial<TCustomConfig>): Date {
|
||||
const retentionHours = getTempChatRetentionHours(config);
|
||||
export function createTempChatExpirationDate(interfaceConfig?: AppConfig['interfaceConfig']): Date {
|
||||
const retentionHours = getTempChatRetentionHours(interfaceConfig);
|
||||
const expiredAt = new Date();
|
||||
expiredAt.setHours(expiredAt.getHours() + retentionHours);
|
||||
return expiredAt;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue