diff --git a/.env.example b/.env.example index a58a37efb61..396dcdf21c2 100644 --- a/.env.example +++ b/.env.example @@ -349,6 +349,11 @@ REGISTRATION_VIOLATION_SCORE=1 CONCURRENT_VIOLATION_SCORE=1 MESSAGE_VIOLATION_SCORE=1 NON_BROWSER_VIOLATION_SCORE=20 +TTS_VIOLATION_SCORE=0 +STT_VIOLATION_SCORE=0 +FORK_VIOLATION_SCORE=0 +IMPORT_VIOLATION_SCORE=0 +FILE_UPLOAD_VIOLATION_SCORE=0 LOGIN_MAX=7 LOGIN_WINDOW=5 @@ -575,6 +580,10 @@ ALLOW_SHARED_LINKS_PUBLIC=true # If you have another service in front of your LibreChat doing compression, disable express based compression here # DISABLE_COMPRESSION=true +# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images +# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images. +# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true + #===================================================# # UI # #===================================================# @@ -592,11 +601,31 @@ HELP_AND_FAQ_URL=https://librechat.ai # REDIS Options # #===============# -# REDIS_URI=10.10.10.10:6379 +# Enable Redis for caching and session storage # USE_REDIS=true -# USE_REDIS_CLUSTER=true -# REDIS_CA=/path/to/ca.crt +# Single Redis instance +# REDIS_URI=redis://127.0.0.1:6379 + +# Redis cluster (multiple nodes) +# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + +# Redis with TLS/SSL encryption and CA certificate +# REDIS_URI=rediss://127.0.0.1:6380 +# REDIS_CA=/path/to/ca-cert.pem + +# Redis authentication (if required) +# REDIS_USERNAME=your_redis_username +# REDIS_PASSWORD=your_redis_password + +# Redis key prefix configuration +# Use environment variable name for dynamic prefix (recommended for cloud deployments) +# REDIS_KEY_PREFIX_VAR=K_REVISION +# Or use static prefix directly +# REDIS_KEY_PREFIX=librechat + +# Redis connection limits +# REDIS_MAX_LISTENERS=40 #==================================================# # Others # diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml new file mode 100644 index 00000000000..1f0e2fd86de --- /dev/null +++ b/.github/workflows/client.yml @@ -0,0 +1,32 @@ +name: Publish `@librechat/client` to NPM + +on: + workflow_dispatch: + inputs: + reason: + description: 'Reason for manual trigger' + required: false + default: 'Manual publish requested' + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + + - name: Check if client package exists + run: | + if [ -d "packages/client" ]; then + echo "Client package directory found" + else + echo "Client package directory not found - workflow ready for future use" + exit 0 + fi + + - name: Placeholder for future publishing + run: echo "Client package publishing workflow is ready" \ No newline at end of file diff --git a/.gitignore b/.gitignore index c9658f17e67..461eef9d2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,12 @@ helm/**/.values.yaml # SAML Idp cert *.cert + +# AI Assistants +/.claude/ +/.cursor/ +/.copilot/ +/.aider/ +/.openai/ +/.tabnine/ +/.codeium diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 0598f0da210..19f7073fe2e 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -108,12 +108,15 @@ class BaseClient { /** * Abstract method to record token usage. Subclasses must implement this method. * If a correction to the token usage is needed, the method should return an object with the corrected token counts. + * Should only be used if `recordCollectedUsage` was not used instead. + * @param {string} [model] * @param {number} promptTokens * @param {number} completionTokens * @returns {Promise} */ - async recordTokenUsage({ promptTokens, completionTokens }) { + async recordTokenUsage({ model, promptTokens, completionTokens }) { logger.debug('[BaseClient] `recordTokenUsage` not implemented.', { + model, promptTokens, completionTokens, }); @@ -197,6 +200,10 @@ class BaseClient { this.currentMessages[this.currentMessages.length - 1].messageId = head; } + if (opts.isRegenerate && responseMessageId.endsWith('_')) { + responseMessageId = crypto.randomUUID(); + } + this.responseMessageId = responseMessageId; return { @@ -737,9 +744,13 @@ class BaseClient { } else { responseMessage.tokenCount = this.getTokenCountForResponse(responseMessage); completionTokens = responseMessage.tokenCount; + await this.recordTokenUsage({ + usage, + promptTokens, + completionTokens, + model: responseMessage.model, + }); } - - await this.recordTokenUsage({ promptTokens, completionTokens, usage }); } if (userMessagePromise) { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 6d449158045..b5ae5b80b6c 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -422,6 +422,46 @@ describe('BaseClient', () => { expect(response).toEqual(expectedResult); }); + test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => { + const mockCrypto = require('crypto'); + const newUUID = 'new-uuid-1234'; + jest.spyOn(mockCrypto, 'randomUUID').mockReturnValue(newUUID); + + const opts = { + isRegenerate: true, + responseMessageId: 'existing-message-id_', + }; + + await TestClient.setMessageOptions(opts); + + expect(TestClient.responseMessageId).toBe(newUUID); + expect(TestClient.responseMessageId).not.toBe('existing-message-id_'); + + mockCrypto.randomUUID.mockRestore(); + }); + + test('should not replace responseMessageId when isRegenerate is false', async () => { + const opts = { + isRegenerate: false, + responseMessageId: 'existing-message-id_', + }; + + await TestClient.setMessageOptions(opts); + + expect(TestClient.responseMessageId).toBe('existing-message-id_'); + }); + + test('should not replace responseMessageId when it does not end with underscore', async () => { + const opts = { + isRegenerate: true, + responseMessageId: 'existing-message-id', + }; + + await TestClient.setMessageOptions(opts); + + expect(TestClient.responseMessageId).toBe('existing-message-id'); + }); + test('sendMessage should work with provided conversationId and parentMessageId', async () => { const userMessage = 'Second message in the conversation'; const opts = { diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 050a0fd8967..27686e04800 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -11,17 +11,25 @@ const { getFiles } = require('~/models/File'); * @param {Object} options * @param {ServerRequest} options.req * @param {Agent['tool_resources']} options.tool_resources + * @param {string} [options.agentId] - The agent ID for file access control * @returns {Promise<{ * files: Array<{ file_id: string; filename: string }>, * toolContext: string * }>} */ const primeFiles = async (options) => { - const { tool_resources } = options; + const { tool_resources, req, agentId } = options; const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? []; - const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles); + const dbFiles = ( + (await getFiles( + { file_id: { $in: file_ids } }, + null, + { text: 0 }, + { userId: req?.user?.id, agentId }, + )) ?? [] + ).concat(resourceFiles); let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index c233c0f762a..5dcefd6df35 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -245,7 +245,13 @@ const loadTools = async ({ authFields: [EnvVar.CODE_API_KEY], }); const codeApiKey = authValues[EnvVar.CODE_API_KEY]; - const { files, toolContext } = await primeCodeFiles(options, codeApiKey); + const { files, toolContext } = await primeCodeFiles( + { + ...options, + agentId: agent?.id, + }, + codeApiKey, + ); if (toolContext) { toolContextMap[tool] = toolContext; } @@ -260,7 +266,10 @@ const loadTools = async ({ continue; } else if (tool === Tools.file_search) { requestedTools[tool] = async () => { - const { files, toolContext } = await primeSearchFiles(options); + const { files, toolContext } = await primeSearchFiles({ + ...options, + agentId: agent?.id, + }); if (toolContext) { toolContextMap[tool] = toolContext; } diff --git a/api/cache/cacheConfig.js b/api/cache/cacheConfig.js new file mode 100644 index 00000000000..534b3f4b356 --- /dev/null +++ b/api/cache/cacheConfig.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const { math, isEnabled } = require('@librechat/api'); + +// To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. +// This prefix is usually the deployment ID, which is often passed to the container or pod as an env var. +// Set REDIS_KEY_PREFIX_VAR to the env var that contains the deployment ID. +const REDIS_KEY_PREFIX_VAR = process.env.REDIS_KEY_PREFIX_VAR; +const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX; +if (REDIS_KEY_PREFIX_VAR && REDIS_KEY_PREFIX) { + throw new Error('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); +} + +const USE_REDIS = isEnabled(process.env.USE_REDIS); +if (USE_REDIS && !process.env.REDIS_URI) { + throw new Error('USE_REDIS is enabled but REDIS_URI is not set.'); +} + +const cacheConfig = { + USE_REDIS, + REDIS_URI: process.env.REDIS_URI, + REDIS_USERNAME: process.env.REDIS_USERNAME, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null, + REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '', + REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40), + + CI: isEnabled(process.env.CI), + DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE), + + BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours +}; + +module.exports = { cacheConfig }; diff --git a/api/cache/cacheConfig.spec.js b/api/cache/cacheConfig.spec.js new file mode 100644 index 00000000000..d931bf0ce1f --- /dev/null +++ b/api/cache/cacheConfig.spec.js @@ -0,0 +1,108 @@ +const fs = require('fs'); + +describe('cacheConfig', () => { + let originalEnv; + let originalReadFileSync; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalReadFileSync = fs.readFileSync; + + // Clear all related env vars first + delete process.env.REDIS_URI; + delete process.env.REDIS_CA; + delete process.env.REDIS_KEY_PREFIX_VAR; + delete process.env.REDIS_KEY_PREFIX; + delete process.env.USE_REDIS; + + // Clear require cache + jest.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + fs.readFileSync = originalReadFileSync; + jest.resetModules(); + }); + + describe('REDIS_KEY_PREFIX validation and resolution', () => { + test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; + process.env.REDIS_KEY_PREFIX = 'manual-prefix'; + + expect(() => { + require('./cacheConfig'); + }).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); + }); + + test('should resolve REDIS_KEY_PREFIX from variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; + process.env.DEPLOYMENT_ID = 'test-deployment-123'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123'); + }); + + test('should use direct REDIS_KEY_PREFIX value', () => { + process.env.REDIS_KEY_PREFIX = 'direct-prefix'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix'); + }); + + test('should default to empty string when no prefix is configured', () => { + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + + test('should handle empty variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR'; + process.env.EMPTY_VAR = ''; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + + test('should handle undefined variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + }); + + describe('USE_REDIS and REDIS_URI validation', () => { + test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => { + process.env.USE_REDIS = 'true'; + + expect(() => { + require('./cacheConfig'); + }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + }); + + test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => { + process.env.USE_REDIS = 'true'; + process.env.REDIS_URI = 'redis://localhost:6379'; + + expect(() => { + require('./cacheConfig'); + }).not.toThrow(); + }); + + test('should handle empty REDIS_URI when USE_REDIS is enabled', () => { + process.env.USE_REDIS = 'true'; + process.env.REDIS_URI = ''; + + expect(() => { + require('./cacheConfig'); + }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + }); + }); + + describe('REDIS_CA file reading', () => { + test('should be null when REDIS_CA is not set', () => { + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_CA).toBeNull(); + }); + }); +}); diff --git a/api/cache/cacheFactory.js b/api/cache/cacheFactory.js new file mode 100644 index 00000000000..f4147f89b80 --- /dev/null +++ b/api/cache/cacheFactory.js @@ -0,0 +1,66 @@ +const KeyvRedis = require('@keyv/redis').default; +const { Keyv } = require('keyv'); +const { cacheConfig } = require('./cacheConfig'); +const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients'); +const { Time } = require('librechat-data-provider'); +const ConnectRedis = require('connect-redis').default; +const MemoryStore = require('memorystore')(require('express-session')); +const { violationFile } = require('./keyvFiles'); +const { RedisStore } = require('rate-limit-redis'); + +/** + * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. + * @param {string} namespace - The cache namespace. + * @param {number} [ttl] - Time to live for cache entries. + * @param {object} [fallbackStore] - Optional fallback store if Redis is not used. + * @returns {Keyv} Cache instance. + */ +const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { + if (cacheConfig.USE_REDIS) { + const keyvRedis = new KeyvRedis(keyvRedisClient); + const cache = new Keyv(keyvRedis, { namespace, ttl }); + keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; + keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; + return cache; + } + if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl }); + return new Keyv({ namespace, ttl }); +}; + +/** + * Creates a cache instance for storing violation data. + * Uses a file-based fallback store if Redis is not enabled. + * @param {string} namespace - The cache namespace for violations. + * @param {number} [ttl] - Time to live for cache entries. + * @returns {Keyv} Cache instance for violations. + */ +const violationCache = (namespace, ttl = undefined) => { + return standardCache(`violations:${namespace}`, ttl, violationFile); +}; + +/** + * Creates a session cache instance using Redis or in-memory store. + * @param {string} namespace - The session namespace. + * @param {number} [ttl] - Time to live for session entries. + * @returns {MemoryStore | ConnectRedis} Session store instance. + */ +const sessionCache = (namespace, ttl = undefined) => { + namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; + if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); + return new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); +}; + +/** + * Creates a rate limiter cache using Redis. + * @param {string} prefix - The key prefix for rate limiting. + * @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used. + */ +const limiterCache = (prefix) => { + if (!prefix) throw new Error('prefix is required'); + if (!cacheConfig.USE_REDIS) return undefined; + prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; + return new RedisStore({ sendCommand, prefix }); +}; +const sendCommand = (...args) => ioredisClient?.call(...args); + +module.exports = { standardCache, sessionCache, violationCache, limiterCache }; diff --git a/api/cache/cacheFactory.spec.js b/api/cache/cacheFactory.spec.js new file mode 100644 index 00000000000..6270a08a161 --- /dev/null +++ b/api/cache/cacheFactory.spec.js @@ -0,0 +1,272 @@ +const { Time } = require('librechat-data-provider'); + +// Mock dependencies first +const mockKeyvRedis = { + namespace: '', + keyPrefixSeparator: '', +}; + +const mockKeyv = jest.fn().mockReturnValue({ mock: 'keyv' }); +const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' }); +const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' }); +const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' }); + +const mockIoredisClient = { + call: jest.fn(), +}; + +const mockKeyvRedisClient = {}; +const mockViolationFile = {}; + +// Mock modules before requiring the main module +jest.mock('@keyv/redis', () => ({ + default: jest.fn().mockImplementation(() => mockKeyvRedis), +})); + +jest.mock('keyv', () => ({ + Keyv: mockKeyv, +})); + +jest.mock('./cacheConfig', () => ({ + cacheConfig: { + USE_REDIS: false, + REDIS_KEY_PREFIX: 'test', + }, +})); + +jest.mock('./redisClients', () => ({ + keyvRedisClient: mockKeyvRedisClient, + ioredisClient: mockIoredisClient, + GLOBAL_PREFIX_SEPARATOR: '::', +})); + +jest.mock('./keyvFiles', () => ({ + violationFile: mockViolationFile, +})); + +jest.mock('connect-redis', () => ({ + default: mockConnectRedis, +})); + +jest.mock('memorystore', () => jest.fn(() => mockMemoryStore)); + +jest.mock('rate-limit-redis', () => ({ + RedisStore: mockRedisStore, +})); + +// Import after mocking +const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory'); +const { cacheConfig } = require('./cacheConfig'); + +describe('cacheFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset cache config mock + cacheConfig.USE_REDIS = false; + cacheConfig.REDIS_KEY_PREFIX = 'test'; + }); + + describe('redisCache', () => { + it('should create Redis cache when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'test-namespace'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); + expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); + expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX); + expect(mockKeyvRedis.keyPrefixSeparator).toBe('::'); + }); + + it('should create Redis cache with undefined ttl when not provided', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'test-namespace'; + + standardCache(namespace); + + expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined }); + }); + + it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'test-namespace'; + const ttl = 3600; + const fallbackStore = { some: 'store' }; + + standardCache(namespace, ttl, fallbackStore); + + expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl }); + }); + + it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'test-namespace'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); + }); + + it('should handle namespace and ttl as undefined', () => { + cacheConfig.USE_REDIS = false; + + standardCache(); + + expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); + }); + }); + + describe('violationCache', () => { + it('should create violation cache with prefixed namespace', () => { + const namespace = 'test-violations'; + const ttl = 7200; + + // We can't easily mock the internal redisCache call since it's in the same module + // But we can test that the function executes without throwing + expect(() => violationCache(namespace, ttl)).not.toThrow(); + }); + + it('should create violation cache with undefined ttl', () => { + const namespace = 'test-violations'; + + violationCache(namespace); + + // The function should call redisCache with violations: prefixed namespace + // Since we can't easily mock the internal redisCache call, we test the behavior + expect(() => violationCache(namespace)).not.toThrow(); + }); + + it('should handle undefined namespace', () => { + expect(() => violationCache(undefined)).not.toThrow(); + }); + }); + + describe('sessionCache', () => { + it('should return MemoryStore when USE_REDIS is false', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'sessions'; + const ttl = 86400; + + const result = sessionCache(namespace, ttl); + + expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY }); + expect(result).toBe(mockMemoryStore()); + }); + + it('should return ConnectRedis when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions'; + const ttl = 86400; + + const result = sessionCache(namespace, ttl); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl, + prefix: `${namespace}:`, + }); + expect(result).toBe(mockConnectRedis()); + }); + + it('should add colon to namespace if not present', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions'; + + sessionCache(namespace); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl: undefined, + prefix: 'sessions:', + }); + }); + + it('should not add colon to namespace if already present', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions:'; + + sessionCache(namespace); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl: undefined, + prefix: 'sessions:', + }); + }); + + it('should handle undefined ttl', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'sessions'; + + sessionCache(namespace); + + expect(mockMemoryStore).toHaveBeenCalledWith({ + ttl: undefined, + checkPeriod: Time.ONE_DAY, + }); + }); + }); + + describe('limiterCache', () => { + it('should return undefined when USE_REDIS is false', () => { + cacheConfig.USE_REDIS = false; + const result = limiterCache('prefix'); + + expect(result).toBeUndefined(); + }); + + it('should return RedisStore when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const result = limiterCache('rate-limit'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: `rate-limit:`, + }); + expect(result).toBe(mockRedisStore()); + }); + + it('should add colon to prefix if not present', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: 'rate-limit:', + }); + }); + + it('should not add colon to prefix if already present', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit:'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: 'rate-limit:', + }); + }); + + it('should pass sendCommand function that calls ioredisClient.call', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit'); + + const sendCommandCall = mockRedisStore.mock.calls[0][0]; + const sendCommand = sendCommandCall.sendCommand; + + // Test that sendCommand properly delegates to ioredisClient.call + const args = ['GET', 'test-key']; + sendCommand(...args); + + expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); + }); + + it('should handle undefined prefix', () => { + cacheConfig.USE_REDIS = true; + expect(() => limiterCache()).toThrow('prefix is required'); + }); + }); +}); diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 0eef7d3fb4b..aca53fcfccf 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -1,113 +1,52 @@ +const { cacheConfig } = require('./cacheConfig'); const { Keyv } = require('keyv'); -const { isEnabled, math } = require('@librechat/api'); const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider'); -const { logFile, violationFile } = require('./keyvFiles'); -const keyvRedis = require('./keyvRedis'); +const { logFile } = require('./keyvFiles'); const keyvMongo = require('./keyvMongo'); - -const { BAN_DURATION, USE_REDIS, DEBUG_MEMORY_CACHE, CI } = process.env ?? {}; - -const duration = math(BAN_DURATION, 7200000); -const isRedisEnabled = isEnabled(USE_REDIS); -const debugMemoryCache = isEnabled(DEBUG_MEMORY_CACHE); - -const createViolationInstance = (namespace) => { - const config = isRedisEnabled ? { store: keyvRedis } : { store: violationFile, namespace }; - return new Keyv(config); -}; - -// Serve cache from memory so no need to clear it on startup/exit -const pending_req = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.PENDING_REQ }); - -const config = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.CONFIG_STORE }); - -const roles = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.ROLES }); - -const mcpTools = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.MCP_TOOLS }); - -const audioRuns = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES }) - : new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES }); - -const messages = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.ONE_MINUTE }) - : new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.ONE_MINUTE }); - -const flows = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) - : new Keyv({ namespace: CacheKeys.FLOWS, ttl: Time.ONE_MINUTE * 3 }); - -const tokenConfig = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) - : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES }); - -const genTitle = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) - : new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES }); - -const s3ExpiryInterval = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) - : new Keyv({ namespace: CacheKeys.S3_EXPIRY_INTERVAL, ttl: Time.THIRTY_MINUTES }); - -const modelQueries = isEnabled(process.env.USE_REDIS) - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.MODEL_QUERIES }); - -const abortKeys = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES }); - -const openIdExchangedTokensCache = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES }) - : new Keyv({ namespace: CacheKeys.OPENID_EXCHANGED_TOKENS, ttl: Time.TEN_MINUTES }); +const { standardCache, sessionCache, violationCache } = require('./cacheFactory'); const namespaces = { - [CacheKeys.ROLES]: roles, - [CacheKeys.MCP_TOOLS]: mcpTools, - [CacheKeys.CONFIG_STORE]: config, - [CacheKeys.PENDING_REQ]: pending_req, - [ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }), - [CacheKeys.ENCODED_DOMAINS]: new Keyv({ + [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }), + [ViolationTypes.LOGINS]: violationCache(ViolationTypes.LOGINS), + [ViolationTypes.CONCURRENT]: violationCache(ViolationTypes.CONCURRENT), + [ViolationTypes.NON_BROWSER]: violationCache(ViolationTypes.NON_BROWSER), + [ViolationTypes.MESSAGE_LIMIT]: violationCache(ViolationTypes.MESSAGE_LIMIT), + [ViolationTypes.REGISTRATIONS]: violationCache(ViolationTypes.REGISTRATIONS), + [ViolationTypes.TOKEN_BALANCE]: violationCache(ViolationTypes.TOKEN_BALANCE), + [ViolationTypes.TTS_LIMIT]: violationCache(ViolationTypes.TTS_LIMIT), + [ViolationTypes.STT_LIMIT]: violationCache(ViolationTypes.STT_LIMIT), + [ViolationTypes.CONVO_ACCESS]: violationCache(ViolationTypes.CONVO_ACCESS), + [ViolationTypes.TOOL_CALL_LIMIT]: violationCache(ViolationTypes.TOOL_CALL_LIMIT), + [ViolationTypes.FILE_UPLOAD_LIMIT]: violationCache(ViolationTypes.FILE_UPLOAD_LIMIT), + [ViolationTypes.VERIFY_EMAIL_LIMIT]: violationCache(ViolationTypes.VERIFY_EMAIL_LIMIT), + [ViolationTypes.RESET_PASSWORD_LIMIT]: violationCache(ViolationTypes.RESET_PASSWORD_LIMIT), + [ViolationTypes.ILLEGAL_MODEL_REQUEST]: violationCache(ViolationTypes.ILLEGAL_MODEL_REQUEST), + [ViolationTypes.BAN]: new Keyv({ store: keyvMongo, - namespace: CacheKeys.ENCODED_DOMAINS, - ttl: 0, + namespace: CacheKeys.BANS, + ttl: cacheConfig.BAN_DURATION, }), - general: new Keyv({ store: logFile, namespace: 'violations' }), - concurrent: createViolationInstance('concurrent'), - non_browser: createViolationInstance('non_browser'), - message_limit: createViolationInstance('message_limit'), - token_balance: createViolationInstance(ViolationTypes.TOKEN_BALANCE), - registrations: createViolationInstance('registrations'), - [ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT), - [ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT), - [ViolationTypes.CONVO_ACCESS]: createViolationInstance(ViolationTypes.CONVO_ACCESS), - [ViolationTypes.TOOL_CALL_LIMIT]: createViolationInstance(ViolationTypes.TOOL_CALL_LIMIT), - [ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT), - [ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT), - [ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance( - ViolationTypes.RESET_PASSWORD_LIMIT, - ), - [ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance( - ViolationTypes.ILLEGAL_MODEL_REQUEST, + + [CacheKeys.OPENID_SESSION]: sessionCache(CacheKeys.OPENID_SESSION), + [CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION), + + [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES), + [CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS), + [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE), + [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ), + [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }), + [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES), + [CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES), + [CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES), + [CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES), + [CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES), + [CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES), + [CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE), + [CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 3), + [CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache( + CacheKeys.OPENID_EXCHANGED_TOKENS, + Time.TEN_MINUTES, ), - logins: createViolationInstance('logins'), - [CacheKeys.ABORT_KEYS]: abortKeys, - [CacheKeys.TOKEN_CONFIG]: tokenConfig, - [CacheKeys.GEN_TITLE]: genTitle, - [CacheKeys.S3_EXPIRY_INTERVAL]: s3ExpiryInterval, - [CacheKeys.MODEL_QUERIES]: modelQueries, - [CacheKeys.AUDIO_RUNS]: audioRuns, - [CacheKeys.MESSAGES]: messages, - [CacheKeys.FLOWS]: flows, - [CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache, }; /** @@ -116,7 +55,10 @@ const namespaces = { */ function getTTLStores() { return Object.values(namespaces).filter( - (store) => store instanceof Keyv && typeof store.opts?.ttl === 'number' && store.opts.ttl > 0, + (store) => + store instanceof Keyv && + parseInt(store.opts?.ttl ?? '0') > 0 && + !store.opts?.store?.constructor?.name?.includes('Redis'), // Only include non-Redis stores ); } @@ -152,18 +94,18 @@ async function clearExpiredFromCache(cache) { if (data?.expires && data.expires <= expiryTime) { const deleted = await cache.opts.store.delete(key); if (!deleted) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`); continue; } cleared++; } } catch (error) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error); const deleted = await cache.opts.store.delete(key); if (!deleted) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`); continue; } @@ -172,7 +114,7 @@ async function clearExpiredFromCache(cache) { } if (cleared > 0) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.log( `[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`, ); @@ -213,7 +155,7 @@ async function clearAllExpiredFromCache() { } } -if (!isRedisEnabled && !isEnabled(CI)) { +if (!cacheConfig.USE_REDIS && !cacheConfig.CI) { /** @type {Set} */ const cleanupIntervals = new Set(); @@ -224,7 +166,7 @@ if (!isRedisEnabled && !isEnabled(CI)) { cleanupIntervals.add(cleanup); - if (debugMemoryCache) { + if (cacheConfig.DEBUG_MEMORY_CACHE) { const monitor = setInterval(() => { const ttlStores = getTTLStores(); const memory = process.memoryUsage(); @@ -245,13 +187,13 @@ if (!isRedisEnabled && !isEnabled(CI)) { } const dispose = () => { - debugMemoryCache && console.log('[Cache] Cleaning up and shutting down...'); + cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...'); cleanupIntervals.forEach((interval) => clearInterval(interval)); cleanupIntervals.clear(); // One final cleanup before exit clearAllExpiredFromCache().then(() => { - debugMemoryCache && console.log('[Cache] Final cleanup completed'); + cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed'); process.exit(0); }); }; diff --git a/api/cache/ioredisClient.js b/api/cache/ioredisClient.js deleted file mode 100644 index cd48459ab45..00000000000 --- a/api/cache/ioredisClient.js +++ /dev/null @@ -1,92 +0,0 @@ -const fs = require('fs'); -const Redis = require('ioredis'); -const { isEnabled } = require('~/server/utils'); -const logger = require('~/config/winston'); - -const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_MAX_LISTENERS } = process.env; - -/** @type {import('ioredis').Redis | import('ioredis').Cluster} */ -let ioredisClient; -const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40; - -function mapURI(uri) { - const regex = - /^(?:(?\w+):\/\/)?(?:(?[^:@]+)(?::(?[^@]+))?@)?(?[\w.-]+)(?::(?\d{1,5}))?$/; - const match = uri.match(regex); - - if (match) { - const { scheme, user, password, host, port } = match.groups; - - return { - scheme: scheme || 'none', - user: user || null, - password: password || null, - host: host || null, - port: port || null, - }; - } else { - const parts = uri.split(':'); - if (parts.length === 2) { - return { - scheme: 'none', - user: null, - password: null, - host: parts[0], - port: parts[1], - }; - } - - return { - scheme: 'none', - user: null, - password: null, - host: uri, - port: null, - }; - } -} - -if (REDIS_URI && isEnabled(USE_REDIS)) { - let redisOptions = null; - - if (REDIS_CA) { - const ca = fs.readFileSync(REDIS_CA); - redisOptions = { tls: { ca } }; - } - - if (isEnabled(USE_REDIS_CLUSTER)) { - const hosts = REDIS_URI.split(',').map((item) => { - var value = mapURI(item); - - return { - host: value.host, - port: value.port, - }; - }); - ioredisClient = new Redis.Cluster(hosts, { redisOptions }); - } else { - ioredisClient = new Redis(REDIS_URI, redisOptions); - } - - ioredisClient.on('ready', () => { - logger.info('IoRedis connection ready'); - }); - ioredisClient.on('reconnecting', () => { - logger.info('IoRedis connection reconnecting'); - }); - ioredisClient.on('end', () => { - logger.info('IoRedis connection ended'); - }); - ioredisClient.on('close', () => { - logger.info('IoRedis connection closed'); - }); - ioredisClient.on('error', (err) => logger.error('IoRedis connection error:', err)); - ioredisClient.setMaxListeners(redis_max_listeners); - logger.info( - '[Optional] IoRedis initialized for rate limiters. If you have issues, disable Redis or restart the server.', - ); -} else { - logger.info('[Optional] IoRedis not initialized for rate limiters.'); -} - -module.exports = ioredisClient; diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js deleted file mode 100644 index 0c3878b34c2..00000000000 --- a/api/cache/keyvRedis.js +++ /dev/null @@ -1,109 +0,0 @@ -const fs = require('fs'); -const ioredis = require('ioredis'); -const KeyvRedis = require('@keyv/redis').default; -const { isEnabled } = require('~/server/utils'); -const logger = require('~/config/winston'); - -const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } = - process.env; - -let keyvRedis; -const redis_prefix = REDIS_KEY_PREFIX || ''; -const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40; - -function mapURI(uri) { - const regex = - /^(?:(?\w+):\/\/)?(?:(?[^:@]+)(?::(?[^@]+))?@)?(?[\w.-]+)(?::(?\d{1,5}))?$/; - const match = uri.match(regex); - - if (match) { - const { scheme, user, password, host, port } = match.groups; - - return { - scheme: scheme || 'none', - user: user || null, - password: password || null, - host: host || null, - port: port || null, - }; - } else { - const parts = uri.split(':'); - if (parts.length === 2) { - return { - scheme: 'none', - user: null, - password: null, - host: parts[0], - port: parts[1], - }; - } - - return { - scheme: 'none', - user: null, - password: null, - host: uri, - port: null, - }; - } -} - -if (REDIS_URI && isEnabled(USE_REDIS)) { - let redisOptions = null; - /** @type {import('@keyv/redis').KeyvRedisOptions} */ - let keyvOpts = { - useRedisSets: false, - keyPrefix: redis_prefix, - }; - - if (REDIS_CA) { - const ca = fs.readFileSync(REDIS_CA); - redisOptions = { tls: { ca } }; - } - - if (isEnabled(USE_REDIS_CLUSTER)) { - const hosts = REDIS_URI.split(',').map((item) => { - var value = mapURI(item); - - return { - host: value.host, - port: value.port, - }; - }); - const cluster = new ioredis.Cluster(hosts, { redisOptions }); - keyvRedis = new KeyvRedis(cluster, keyvOpts); - } else { - keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts); - } - - const pingInterval = setInterval( - () => { - logger.debug('KeyvRedis ping'); - keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err)); - }, - 5 * 60 * 1000, - ); - - keyvRedis.on('ready', () => { - logger.info('KeyvRedis connection ready'); - }); - keyvRedis.on('reconnecting', () => { - logger.info('KeyvRedis connection reconnecting'); - }); - keyvRedis.on('end', () => { - logger.info('KeyvRedis connection ended'); - }); - keyvRedis.on('close', () => { - clearInterval(pingInterval); - logger.info('KeyvRedis connection closed'); - }); - keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err)); - keyvRedis.setMaxListeners(redis_max_listeners); - logger.info( - '[Optional] Redis initialized. If you have issues, or seeing older values, disable it or flush cache to refresh values.', - ); -} else { - logger.info('[Optional] Redis not initialized.'); -} - -module.exports = keyvRedis; diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js index a3162bbfacf..16dc2e4eaea 100644 --- a/api/cache/logViolation.js +++ b/api/cache/logViolation.js @@ -1,4 +1,5 @@ const { isEnabled } = require('~/server/utils'); +const { ViolationTypes } = require('librechat-data-provider'); const getLogStores = require('./getLogStores'); const banViolation = require('./banViolation'); @@ -9,14 +10,14 @@ const banViolation = require('./banViolation'); * @param {Object} res - Express response object. * @param {string} type - The type of violation. * @param {Object} errorMessage - The error message to log. - * @param {number} [score=1] - The severity of the violation. Defaults to 1 + * @param {number | string} [score=1] - The severity of the violation. Defaults to 1 */ const logViolation = async (req, res, type, errorMessage, score = 1) => { const userId = req.user?.id ?? req.user?._id; if (!userId) { return; } - const logs = getLogStores('general'); + const logs = getLogStores(ViolationTypes.GENERAL); const violationLogs = getLogStores(type); const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId; diff --git a/api/cache/redisClients.js b/api/cache/redisClients.js new file mode 100644 index 00000000000..1a653ba13be --- /dev/null +++ b/api/cache/redisClients.js @@ -0,0 +1,57 @@ +const IoRedis = require('ioredis'); +const { cacheConfig } = require('./cacheConfig'); +const { createClient, createCluster } = require('@keyv/redis'); + +const GLOBAL_PREFIX_SEPARATOR = '::'; + +const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)); +const username = urls?.[0].username || cacheConfig.REDIS_USERNAME; +const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD; +const ca = cacheConfig.REDIS_CA; + +/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */ +let ioredisClient = null; +if (cacheConfig.USE_REDIS) { + const redisOptions = { + username: username, + password: password, + tls: ca ? { ca } : undefined, + keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`, + maxListeners: cacheConfig.REDIS_MAX_LISTENERS, + }; + + ioredisClient = + urls.length === 1 + ? new IoRedis(cacheConfig.REDIS_URI, redisOptions) + : new IoRedis.Cluster(cacheConfig.REDIS_URI, { redisOptions }); + + // Pinging the Redis server every 5 minutes to keep the connection alive + const pingInterval = setInterval(() => ioredisClient.ping(), 5 * 60 * 1000); + ioredisClient.on('close', () => clearInterval(pingInterval)); + ioredisClient.on('end', () => clearInterval(pingInterval)); +} + +/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */ +let keyvRedisClient = null; +if (cacheConfig.USE_REDIS) { + // ** WARNING ** Keyv Redis client does not support Prefix like ioredis above. + // The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js + const redisOptions = { username, password, socket: { tls: ca != null, ca } }; + + keyvRedisClient = + urls.length === 1 + ? createClient({ url: cacheConfig.REDIS_URI, ...redisOptions }) + : createCluster({ + rootNodes: cacheConfig.REDIS_URI.split(',').map((url) => ({ url })), + defaults: redisOptions, + }); + + keyvRedisClient.setMaxListeners(cacheConfig.REDIS_MAX_LISTENERS); + + // Pinging the Redis server every 5 minutes to keep the connection alive + const keyvPingInterval = setInterval(() => keyvRedisClient.ping(), 5 * 60 * 1000); + keyvRedisClient.on('disconnect', () => clearInterval(keyvPingInterval)); + keyvRedisClient.on('end', () => clearInterval(keyvPingInterval)); +} + +module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 698762d43d6..b237c41e939 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -1,6 +1,6 @@ const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const getCustomConfig = require('~/server/services/Config/loadCustomConfig'); +const getCustomConfig = require('~/server/services/Config/getCustomConfig'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); diff --git a/api/models/File.js b/api/models/File.js index 1ee943131d8..6d1d77a2206 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,5 +1,7 @@ const { logger } = require('@librechat/data-schemas'); -const { EToolResources, FileContext } = require('librechat-data-provider'); +const { EToolResources, FileContext, Constants } = require('librechat-data-provider'); +const { getProjectByName } = require('./Project'); +const { getAgent } = require('./Agent'); const { File } = require('~/db/models'); /** @@ -12,17 +14,119 @@ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); }; +/** + * Checks if a user has access to multiple files through a shared agent (batch operation) + * @param {string} userId - The user ID to check access for + * @param {string[]} fileIds - Array of file IDs to check + * @param {string} agentId - The agent ID that might grant access + * @returns {Promise>} Map of fileId to access status + */ +const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => { + const accessMap = new Map(); + + // Initialize all files as no access + fileIds.forEach((fileId) => accessMap.set(fileId, false)); + + try { + const agent = await getAgent({ id: agentId }); + + if (!agent) { + return accessMap; + } + + // Check if user is the author - if so, grant access to all files + if (agent.author.toString() === userId) { + fileIds.forEach((fileId) => accessMap.set(fileId, true)); + return accessMap; + } + + // Check if agent is shared with the user via projects + if (!agent.projectIds || agent.projectIds.length === 0) { + return accessMap; + } + + // Check if agent is in global project + const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); + if ( + !globalProject || + !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) + ) { + return accessMap; + } + + // Agent is globally shared - check if it's collaborative + if (!agent.isCollaborative) { + return accessMap; + } + + // Agent is globally shared and collaborative - check which files are actually attached + const attachedFileIds = new Set(); + if (agent.tool_resources) { + for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { + if (resource?.file_ids && Array.isArray(resource.file_ids)) { + resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); + } + } + } + + // Grant access only to files that are attached to this agent + fileIds.forEach((fileId) => { + if (attachedFileIds.has(fileId)) { + accessMap.set(fileId, true); + } + }); + + return accessMap; + } catch (error) { + logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); + return accessMap; + } +}; + /** * Retrieves files matching a given filter, sorted by the most recently updated. * @param {Object} filter - The filter criteria to apply. * @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * Default excludes the 'text' field. + * @param {Object} [options] - Additional options + * @param {string} [options.userId] - User ID for access control + * @param {string} [options.agentId] - Agent ID that might grant access to files * @returns {Promise>} A promise that resolves to an array of file documents. */ -const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { +const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; - return await File.find(filter).select(selectFields).sort(sortOptions).lean(); + const files = await File.find(filter).select(selectFields).sort(sortOptions).lean(); + + // If userId and agentId are provided, filter files based on access + if (options.userId && options.agentId) { + // Collect file IDs that need access check + const filesToCheck = []; + const ownedFiles = []; + + for (const file of files) { + if (file.user && file.user.toString() === options.userId) { + ownedFiles.push(file); + } else { + filesToCheck.push(file); + } + } + + if (filesToCheck.length === 0) { + return ownedFiles; + } + + // Batch check access for all non-owned files + const fileIds = filesToCheck.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent(options.userId, fileIds, options.agentId); + + // Filter files based on access + const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); + + return [...ownedFiles, ...accessibleFiles]; + } + + return files; }; /** @@ -176,4 +280,5 @@ module.exports = { deleteFiles, deleteFileByFilter, batchUpdateFiles, + hasAccessToFilesViaAgent, }; diff --git a/api/models/File.spec.js b/api/models/File.spec.js new file mode 100644 index 00000000000..9861571e33b --- /dev/null +++ b/api/models/File.spec.js @@ -0,0 +1,264 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { fileSchema } = require('@librechat/data-schemas'); +const { agentSchema } = require('@librechat/data-schemas'); +const { projectSchema } = require('@librechat/data-schemas'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; +const { getFiles, createFile } = require('./File'); +const { getProjectByName } = require('./Project'); +const { createAgent } = require('./Agent'); + +let File; +let Agent; +let Project; + +describe('File Access Control', () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + File = mongoose.models.File || mongoose.model('File', fileSchema); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + Project = mongoose.models.Project || mongoose.model('Project', projectSchema); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await File.deleteMany({}); + await Agent.deleteMany({}); + await Project.deleteMany({}); + }); + + describe('hasAccessToFilesViaAgent', () => { + it('should efficiently check access for multiple files at once', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()]; + + // Create files + for (const fileId of fileIds) { + await createFile({ + user: authorId, + file_id: fileId, + filename: `file-${fileId}.txt`, + filepath: `/uploads/${fileId}`, + }); + } + + // Create agent with only first two files attached + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileIds[0], fileIds[1]], + }, + }, + }); + + // Get or create global project + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + + // Share agent globally + await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + + // Check access for all files + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Should have access only to the first two files + expect(accessMap.get(fileIds[0])).toBe(true); + expect(accessMap.get(fileIds[1])).toBe(true); + expect(accessMap.get(fileIds[2])).toBe(false); + expect(accessMap.get(fileIds[3])).toBe(false); + }); + + it('should grant access to all files when user is the agent author', async () => { + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4(), uuidv4()]; + + // Create agent + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + tool_resources: { + file_search: { + file_ids: [fileIds[0]], // Only one file attached + }, + }, + }); + + // Check access as the author + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId); + + // Author should have access to all files + expect(accessMap.get(fileIds[0])).toBe(true); + expect(accessMap.get(fileIds[1])).toBe(true); + expect(accessMap.get(fileIds[2])).toBe(true); + }); + + it('should handle non-existent agent gracefully', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const fileIds = [uuidv4(), uuidv4()]; + + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent'); + + // Should have no access to any files + expect(accessMap.get(fileIds[0])).toBe(false); + expect(accessMap.get(fileIds[1])).toBe(false); + }); + + it('should deny access when agent is not collaborative', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4()]; + + // Create agent with files but isCollaborative: false + await createAgent({ + id: agentId, + name: 'Non-Collaborative Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: false, + tool_resources: { + file_search: { + file_ids: fileIds, + }, + }, + }); + + // Get or create global project + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + + // Share agent globally + await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + + // Check access for files + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Should have no access to any files when isCollaborative is false + expect(accessMap.get(fileIds[0])).toBe(false); + expect(accessMap.get(fileIds[1])).toBe(false); + }); + }); + + describe('getFiles with agent access control', () => { + test('should return files owned by user and files accessible through agent', async () => { + const authorId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + const agentId = `agent_${uuidv4()}`; + const ownedFileId = `file_${uuidv4()}`; + const sharedFileId = `file_${uuidv4()}`; + const inaccessibleFileId = `file_${uuidv4()}`; + + // Create/get global project using getProjectByName which will upsert + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME); + + // Create agent with shared file + await createAgent({ + id: agentId, + name: 'Shared Agent', + provider: 'test', + model: 'test-model', + author: authorId, + projectIds: [globalProject._id], + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [sharedFileId], + }, + }, + }); + + // Create files + await createFile({ + file_id: ownedFileId, + user: userId, + filename: 'owned.txt', + filepath: '/uploads/owned.txt', + type: 'text/plain', + bytes: 100, + }); + + await createFile({ + file_id: sharedFileId, + user: authorId, + filename: 'shared.txt', + filepath: '/uploads/shared.txt', + type: 'text/plain', + bytes: 200, + embedded: true, + }); + + await createFile({ + file_id: inaccessibleFileId, + user: authorId, + filename: 'inaccessible.txt', + filepath: '/uploads/inaccessible.txt', + type: 'text/plain', + bytes: 300, + }); + + // Get files with access control + const files = await getFiles( + { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } }, + null, + { text: 0 }, + { userId: userId.toString(), agentId }, + ); + + expect(files).toHaveLength(2); + expect(files.map((f) => f.file_id)).toContain(ownedFileId); + expect(files.map((f) => f.file_id)).toContain(sharedFileId); + expect(files.map((f) => f.file_id)).not.toContain(inaccessibleFileId); + }); + + test('should return all files when no userId/agentId provided', async () => { + const userId = new mongoose.Types.ObjectId(); + const fileId1 = `file_${uuidv4()}`; + const fileId2 = `file_${uuidv4()}`; + + await createFile({ + file_id: fileId1, + user: userId, + filename: 'file1.txt', + filepath: '/uploads/file1.txt', + type: 'text/plain', + bytes: 100, + }); + + await createFile({ + file_id: fileId2, + user: new mongoose.Types.ObjectId(), + filename: 'file2.txt', + filepath: '/uploads/file2.txt', + type: 'text/plain', + bytes: 200, + }); + + const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } }); + expect(files).toHaveLength(2); + }); + }); +}); diff --git a/api/models/Message.js b/api/models/Message.js index c200c5f4d46..3d5eee6ec93 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); -const getCustomConfig = require('~/server/services/Config/loadCustomConfig'); +const getCustomConfig = require('~/server/services/Config/getCustomConfig'); const { Message } = require('~/db/models'); const idSchema = z.string().uuid(); diff --git a/api/models/tx.js b/api/models/tx.js index f3ba38652d2..b6d627620a3 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -135,10 +135,11 @@ const tokenValues = Object.assign( 'grok-2-1212': { prompt: 2.0, completion: 10.0 }, 'grok-2-latest': { prompt: 2.0, completion: 10.0 }, 'grok-2': { prompt: 2.0, completion: 10.0 }, - 'grok-3-mini-fast': { prompt: 0.4, completion: 4 }, + 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, 'grok-3': { prompt: 3.0, completion: 15.0 }, + 'grok-4': { prompt: 3.0, completion: 15.0 }, 'grok-beta': { prompt: 5.0, completion: 15.0 }, 'mistral-large': { prompt: 2.0, completion: 6.0 }, 'pixtral-large': { prompt: 2.0, completion: 6.0 }, diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 1c886c1994a..114b7b892dc 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -636,6 +636,15 @@ describe('Grok Model Tests - Pricing', () => { ); }); + test('should return correct prompt and completion rates for Grok 4 model', () => { + expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'prompt' })).toBe( + tokenValues['grok-4'].prompt, + ); + expect(getMultiplier({ model: 'grok-4-0709', tokenType: 'completion' })).toBe( + tokenValues['grok-4'].completion, + ); + }); + test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => { expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe( tokenValues['grok-3'].prompt, @@ -662,6 +671,15 @@ describe('Grok Model Tests - Pricing', () => { tokenValues['grok-3-mini-fast'].completion, ); }); + + test('should return correct prompt and completion rates for Grok 4 model with prefixes', () => { + expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'prompt' })).toBe( + tokenValues['grok-4'].prompt, + ); + expect(getMultiplier({ model: 'xai/grok-4-0709', tokenType: 'completion' })).toBe( + tokenValues['grok-4'].completion, + ); + }); }); }); diff --git a/api/package.json b/api/package.json index 25cde920565..d0c8764f930 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.56", + "@librechat/agents": "^2.4.61", "@librechat/api": "*", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index f7aad84aeb1..a0af142938e 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -1,11 +1,10 @@ const { logger } = require('@librechat/data-schemas'); -const { CacheKeys, AuthType } = require('librechat-data-provider'); +const { CacheKeys, AuthType, Constants } = require('librechat-data-provider'); const { getCustomConfig, getCachedTools } = require('~/server/services/Config'); const { getToolkitKey } = require('~/server/services/ToolService'); const { getMCPManager, getFlowStateManager } = require('~/config'); const { availableTools } = require('~/app/clients/tools'); const { getLogStores } = require('~/cache'); -const { Constants } = require('librechat-data-provider'); /** * Filters out duplicate plugins from the list of plugins. @@ -140,9 +139,9 @@ function createGetServerTools() { const getAvailableTools = async (req, res) => { try { const cache = getLogStores(CacheKeys.CONFIG_STORE); - const cachedTools = await cache.get(CacheKeys.TOOLS); - if (cachedTools) { - res.status(200).json(cachedTools); + const cachedToolsArray = await cache.get(CacheKeys.TOOLS); + if (cachedToolsArray) { + res.status(200).json(cachedToolsArray); return; } @@ -173,7 +172,7 @@ const getAvailableTools = async (req, res) => { } }); - const toolDefinitions = await getCachedTools({ includeGlobal: true }); + const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {}; const toolsOutput = []; for (const plugin of authenticatedPlugins) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 1bdf809d913..97cf83ee9b5 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1,5 +1,7 @@ require('events').EventEmitter.defaultMaxListeners = 100; const { logger } = require('@librechat/data-schemas'); +const { DynamicStructuredTool } = require('@langchain/core/tools'); +const { getBufferString, HumanMessage } = require('@langchain/core/messages'); const { sendEvent, createRun, @@ -24,20 +26,22 @@ const { VisionModes, ContentTypes, EModelEndpoint, - KnownEndpoints, PermissionTypes, isAgentsEndpoint, AgentCapabilities, bedrockInputSchema, removeNullishValues, } = require('librechat-data-provider'); -const { DynamicStructuredTool } = require('@langchain/core/tools'); -const { getBufferString, HumanMessage } = require('@langchain/core/messages'); -const { createGetMCPAuthMap, checkCapability } = require('~/server/services/Config'); +const { + findPluginAuthsByKeys, + getFormattedMemories, + deleteMemory, + setMemory, +} = require('~/models'); +const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config'); const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); -const { getFormattedMemories, deleteMemory, setMemory } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { getProviderConfig } = require('~/server/services/Endpoints'); const BaseClient = require('~/app/clients/BaseClient'); @@ -54,6 +58,7 @@ const omitTitleOptions = new Set([ 'thinkingBudget', 'includeThoughts', 'maxOutputTokens', + 'additionalModelRequestFields', ]); /** @@ -70,8 +75,6 @@ const payloadParser = ({ req, agent, endpoint }) => { return req.body.endpointOption.model_parameters; }; -const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]); - const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi]; function createTokenCounter(encoding) { @@ -452,6 +455,12 @@ class AgentClient extends BaseClient { res: this.options.res, agent: prelimAgent, allowedProviders, + endpointOption: { + endpoint: + prelimAgent.id !== Constants.EPHEMERAL_AGENT_ID + ? EModelEndpoint.agents + : memoryConfig.agent?.provider, + }, }); if (!agent) { @@ -700,17 +709,12 @@ class AgentClient extends BaseClient { version: 'v2', }; - const getUserMCPAuthMap = await createGetMCPAuthMap(); - const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name)); let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages( payload, this.indexTokenCountMap, toolSet, ); - if (legacyContentEndpoints.has(this.options.agent.endpoint?.toLowerCase())) { - initialMessages = formatContentStrings(initialMessages); - } /** * @@ -774,6 +778,9 @@ class AgentClient extends BaseClient { } let messages = _messages; + if (agent.useLegacyContent === true) { + messages = formatContentStrings(messages); + } if ( agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes( 'prompt-caching', @@ -822,10 +829,11 @@ class AgentClient extends BaseClient { } try { - if (getUserMCPAuthMap) { - config.configurable.userMCPAuthMap = await getUserMCPAuthMap({ + if (await hasCustomUserVars()) { + config.configurable.userMCPAuthMap = await getMCPAuthMap({ tools: agent.tools, userId: this.options.req.user.id, + findPluginAuthsByKeys, }); } } catch (err) { @@ -1043,6 +1051,12 @@ class AgentClient extends BaseClient { options.llmConfig?.azureOpenAIApiInstanceName == null ) { provider = Providers.OPENAI; + } else if ( + endpoint === EModelEndpoint.azureOpenAI && + options.llmConfig?.azureOpenAIApiInstanceName != null && + provider !== Providers.AZURE + ) { + provider = Providers.AZURE; } /** @type {import('@librechat/agents').ClientOptions} */ @@ -1121,8 +1135,52 @@ class AgentClient extends BaseClient { } } - /** Silent method, as `recordCollectedUsage` is used instead */ - async recordTokenUsage() {} + /** + * @param {object} params + * @param {number} params.promptTokens + * @param {number} params.completionTokens + * @param {OpenAIUsageMetadata} [params.usage] + * @param {string} [params.model] + * @param {string} [params.context='message'] + * @returns {Promise} + */ + async recordTokenUsage({ model, promptTokens, completionTokens, usage, context = 'message' }) { + try { + await spendTokens( + { + model, + context, + conversationId: this.conversationId, + user: this.user ?? this.options.req.user?.id, + endpointTokenConfig: this.options.endpointTokenConfig, + }, + { promptTokens, completionTokens }, + ); + + if ( + usage && + typeof usage === 'object' && + 'reasoning_tokens' in usage && + typeof usage.reasoning_tokens === 'number' + ) { + await spendTokens( + { + model, + context: 'reasoning', + conversationId: this.conversationId, + user: this.user ?? this.options.req.user?.id, + endpointTokenConfig: this.options.endpointTokenConfig, + }, + { completionTokens: usage.reasoning_tokens }, + ); + } + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #recordTokenUsage] Error recording token usage', + error, + ); + } + } getEncoding() { return 'o200k_base'; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 2c8e424b5de..b4a0e40b245 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -12,6 +12,7 @@ const { saveMessage } = require('~/models'); const AgentController = async (req, res, next, initializeClient, addTitle) => { let { text, + isRegenerate, endpointOption, conversationId, isContinued = false, @@ -167,6 +168,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { onStart, getReqData, isContinued, + isRegenerate, editedContent, conversationId, parentMessageId, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 4aa50521cfa..c3c6167605f 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -391,6 +391,22 @@ const uploadAgentAvatarHandler = async (req, res) => { return res.status(400).json({ message: 'Agent ID is required' }); } + const isAdmin = req.user.role === SystemRoles.ADMIN; + const existingAgent = await getAgent({ id: agent_id }); + + if (!existingAgent) { + return res.status(404).json({ error: 'Agent not found' }); + } + + const isAuthor = existingAgent.author.toString() === req.user.id; + const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor; + + if (!hasEditPermission) { + return res.status(403).json({ + error: 'You do not have permission to modify this non-collaborative agent', + }); + } + const buffer = await fs.readFile(req.file.path); const fileStrategy = req.app.locals.fileStrategy; @@ -413,14 +429,7 @@ const uploadAgentAvatarHandler = async (req, res) => { source: fileStrategy, }; - let _avatar; - try { - const agent = await getAgent({ id: agent_id }); - _avatar = agent.avatar; - } catch (error) { - logger.error('[/:agent_id/avatar] Error fetching agent', error); - _avatar = {}; - } + let _avatar = existingAgent.avatar; if (_avatar && _avatar.source) { const { deleteFile } = getStrategyFunctions(_avatar.source); @@ -442,7 +451,7 @@ const uploadAgentAvatarHandler = async (req, res) => { }; promises.push( - await updateAgent({ id: agent_id, author: req.user.id }, data, { + await updateAgent({ id: agent_id }, data, { updatingUserId: req.user.id, }), ); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index d302bf87433..a44fd4b75db 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -1,3 +1,4 @@ +const { handleError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { EndpointURLs, @@ -14,7 +15,6 @@ const openAI = require('~/server/services/Endpoints/openAI'); const agents = require('~/server/services/Endpoints/agents'); const custom = require('~/server/services/Endpoints/custom'); const google = require('~/server/services/Endpoints/google'); -const { handleError } = require('~/server/utils'); const buildFunction = { [EModelEndpoint.openAI]: openAI.buildOptions, diff --git a/api/server/middleware/concurrentLimiter.js b/api/server/middleware/concurrentLimiter.js index 73de65dd25a..79de88609bf 100644 --- a/api/server/middleware/concurrentLimiter.js +++ b/api/server/middleware/concurrentLimiter.js @@ -1,4 +1,4 @@ -const { Time, CacheKeys } = require('librechat-data-provider'); +const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); const clearPendingReq = require('~/cache/clearPendingReq'); const { logViolation, getLogStores } = require('~/cache'); const { isEnabled } = require('~/server/utils'); @@ -37,7 +37,7 @@ const concurrentLimiter = async (req, res, next) => { const userId = req.user?.id ?? req.user?._id ?? ''; const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1); - const type = 'concurrent'; + const type = ViolationTypes.CONCURRENT; const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`; const pendingRequests = +((await cache.get(key)) ?? 0); diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js index c486c46fbd4..35fda10e945 100644 --- a/api/server/middleware/limiters/forkLimiters.js +++ b/api/server/middleware/limiters/forkLimiters.js @@ -1,9 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { isEnabled } = require('@librechat/api'); -const { RedisStore } = require('rate-limit-redis'); -const { logger } = require('@librechat/data-schemas'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -11,6 +8,7 @@ const getEnvironmentVariables = () => { const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1; const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7; const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1; + const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE; const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000; const forkIpMax = FORK_IP_MAX; @@ -27,12 +25,18 @@ const getEnvironmentVariables = () => { forkUserWindowMs, forkUserMax, forkUserWindowInMinutes, + forkViolationScore: FORK_VIOLATION_SCORE, }; }; const createForkHandler = (ip = true) => { - const { forkIpMax, forkIpWindowInMinutes, forkUserMax, forkUserWindowInMinutes } = - getEnvironmentVariables(); + const { + forkIpMax, + forkUserMax, + forkViolationScore, + forkIpWindowInMinutes, + forkUserWindowInMinutes, + } = getEnvironmentVariables(); return async (req, res) => { const type = ViolationTypes.FILE_UPLOAD_LIMIT; @@ -43,7 +47,7 @@ const createForkHandler = (ip = true) => { windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, forkViolationScore); res.status(429).json({ message: 'Too many conversation fork requests. Try again later' }); }; }; @@ -55,6 +59,7 @@ const createForkLimiters = () => { windowMs: forkIpWindowMs, max: forkIpMax, handler: createForkHandler(), + store: limiterCache('fork_ip_limiter'), }; const userLimiterOptions = { windowMs: forkUserWindowMs, @@ -63,23 +68,9 @@ const createForkLimiters = () => { keyGenerator: function (req) { return req.user?.id; }, + store: limiterCache('fork_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for fork rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'fork_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'fork_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const forkIpLimiter = rateLimit(ipLimiterOptions); const forkUserLimiter = rateLimit(userLimiterOptions); return { forkIpLimiter, forkUserLimiter }; diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js index 88c4e981454..0d8204393f9 100644 --- a/api/server/middleware/limiters/importLimiters.js +++ b/api/server/middleware/limiters/importLimiters.js @@ -1,9 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { isEnabled } = require('@librechat/api'); -const { RedisStore } = require('rate-limit-redis'); -const { logger } = require('@librechat/data-schemas'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -11,6 +8,7 @@ const getEnvironmentVariables = () => { const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15; const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50; const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15; + const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE; const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000; const importIpMax = IMPORT_IP_MAX; @@ -27,12 +25,18 @@ const getEnvironmentVariables = () => { importUserWindowMs, importUserMax, importUserWindowInMinutes, + importViolationScore: IMPORT_VIOLATION_SCORE, }; }; const createImportHandler = (ip = true) => { - const { importIpMax, importIpWindowInMinutes, importUserMax, importUserWindowInMinutes } = - getEnvironmentVariables(); + const { + importIpMax, + importUserMax, + importViolationScore, + importIpWindowInMinutes, + importUserWindowInMinutes, + } = getEnvironmentVariables(); return async (req, res) => { const type = ViolationTypes.FILE_UPLOAD_LIMIT; @@ -43,7 +47,7 @@ const createImportHandler = (ip = true) => { windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, importViolationScore); res.status(429).json({ message: 'Too many conversation import requests. Try again later' }); }; }; @@ -56,6 +60,7 @@ const createImportLimiters = () => { windowMs: importIpWindowMs, max: importIpMax, handler: createImportHandler(), + store: limiterCache('import_ip_limiter'), }; const userLimiterOptions = { windowMs: importUserWindowMs, @@ -64,23 +69,9 @@ const createImportLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('import_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for import rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'import_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'import_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const importIpLimiter = rateLimit(ipLimiterOptions); const importUserLimiter = rateLimit(userLimiterOptions); return { importIpLimiter, importUserLimiter }; diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js index d57af29414b..cc21f687926 100644 --- a/api/server/middleware/limiters/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,9 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; const windowMs = LOGIN_WINDOW * 60 * 1000; @@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000; const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`; const handler = async (req, res) => { - const type = 'logins'; + const type = ViolationTypes.LOGINS; const errorMessage = { type, max, @@ -28,17 +27,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('login_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for login rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'login_limiter:', - }); - limiterOptions.store = store; -} - const loginLimiter = rateLimit(limiterOptions); module.exports = loginLimiter; diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js index 4191c9fe7c2..553d39959b9 100644 --- a/api/server/middleware/limiters/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,16 +1,15 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); +const { ViolationTypes } = require('librechat-data-provider'); const denyRequest = require('~/server/middleware/denyRequest'); -const ioredisClient = require('~/cache/ioredisClient'); -const { isEnabled } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { MESSAGE_IP_MAX = 40, MESSAGE_IP_WINDOW = 1, MESSAGE_USER_MAX = 40, MESSAGE_USER_WINDOW = 1, + MESSAGE_VIOLATION_SCORE: score, } = process.env; const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000; @@ -31,7 +30,7 @@ const userWindowInMinutes = userWindowMs / 60000; */ const createHandler = (ip = true) => { return async (req, res) => { - const type = 'message_limit'; + const type = ViolationTypes.MESSAGE_LIMIT; const errorMessage = { type, max: ip ? ipMax : userMax, @@ -39,7 +38,7 @@ const createHandler = (ip = true) => { windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, score); return await denyRequest(req, res, errorMessage); }; }; @@ -51,6 +50,7 @@ const ipLimiterOptions = { windowMs: ipWindowMs, max: ipMax, handler: createHandler(), + store: limiterCache('message_ip_limiter'), }; const userLimiterOptions = { @@ -60,23 +60,9 @@ const userLimiterOptions = { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('message_user_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for message rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'message_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'message_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; -} - /** * Message request rate limiter by IP */ diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js index 7d38b3044e6..15c91eba376 100644 --- a/api/server/middleware/limiters/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,9 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; const windowMs = REGISTER_WINDOW * 60 * 1000; @@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000; const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`; const handler = async (req, res) => { - const type = 'registrations'; + const type = ViolationTypes.REGISTRATIONS; const errorMessage = { type, max, @@ -28,17 +27,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('register_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for register rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'register_limiter:', - }); - limiterOptions.store = store; -} - const registerLimiter = rateLimit(limiterOptions); module.exports = registerLimiter; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js index 673b23e8e51..1905d5f2bcc 100644 --- a/api/server/middleware/limiters/resetPasswordLimiter.js +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -1,10 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { RESET_PASSWORD_WINDOW = 2, @@ -33,17 +31,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('reset_password_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for reset password rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'reset_password_limiter:', - }); - limiterOptions.store = store; -} - const resetPasswordLimiter = rateLimit(limiterOptions); module.exports = resetPasswordLimiter; diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js index 72ed3af6a3e..138e68caa76 100644 --- a/api/server/middleware/limiters/sttLimiters.js +++ b/api/server/middleware/limiters/sttLimiters.js @@ -1,16 +1,14 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); const getEnvironmentVariables = () => { const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100; const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1; const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50; const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1; + const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE; const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000; const sttIpMax = STT_IP_MAX; @@ -27,11 +25,12 @@ const getEnvironmentVariables = () => { sttUserWindowMs, sttUserMax, sttUserWindowInMinutes, + sttViolationScore: STT_VIOLATION_SCORE, }; }; const createSTTHandler = (ip = true) => { - const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes } = + const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } = getEnvironmentVariables(); return async (req, res) => { @@ -43,7 +42,7 @@ const createSTTHandler = (ip = true) => { windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, sttViolationScore); res.status(429).json({ message: 'Too many STT requests. Try again later' }); }; }; @@ -55,6 +54,7 @@ const createSTTLimiters = () => { windowMs: sttIpWindowMs, max: sttIpMax, handler: createSTTHandler(), + store: limiterCache('stt_ip_limiter'), }; const userLimiterOptions = { @@ -64,23 +64,9 @@ const createSTTLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('stt_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for STT rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'stt_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'stt_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const sttIpLimiter = rateLimit(ipLimiterOptions); const sttUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js index 482744a3e94..28c1f789121 100644 --- a/api/server/middleware/limiters/toolCallLimiter.js +++ b/api/server/middleware/limiters/toolCallLimiter.js @@ -1,10 +1,9 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); + +const { TOOL_CALL_VIOLATION_SCORE: score } = process.env; const handler = async (req, res) => { const type = ViolationTypes.TOOL_CALL_LIMIT; @@ -15,7 +14,7 @@ const handler = async (req, res) => { windowInMinutes: 1, }; - await logViolation(req, res, type, errorMessage, 0); + await logViolation(req, res, type, errorMessage, score); res.status(429).json({ message: 'Too many tool call requests. Try again later' }); }; @@ -26,17 +25,9 @@ const limiterOptions = { keyGenerator: function (req) { return req.user?.id; }, + store: limiterCache('tool_call_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for tool call rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'tool_call_limiter:', - }); - limiterOptions.store = store; -} - const toolCallLimiter = rateLimit(limiterOptions); module.exports = toolCallLimiter; diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js index 9054a6beb1d..89742c88a8f 100644 --- a/api/server/middleware/limiters/ttsLimiters.js +++ b/api/server/middleware/limiters/ttsLimiters.js @@ -1,16 +1,14 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { limiterCache } = require('~/cache/cacheFactory'); const getEnvironmentVariables = () => { const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100; const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1; const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50; const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1; + const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE; const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000; const ttsIpMax = TTS_IP_MAX; @@ -27,11 +25,12 @@ const getEnvironmentVariables = () => { ttsUserWindowMs, ttsUserMax, ttsUserWindowInMinutes, + ttsViolationScore: TTS_VIOLATION_SCORE, }; }; const createTTSHandler = (ip = true) => { - const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes } = + const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } = getEnvironmentVariables(); return async (req, res) => { @@ -43,7 +42,7 @@ const createTTSHandler = (ip = true) => { windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, ttsViolationScore); res.status(429).json({ message: 'Too many TTS requests. Try again later' }); }; }; @@ -55,32 +54,19 @@ const createTTSLimiters = () => { windowMs: ttsIpWindowMs, max: ttsIpMax, handler: createTTSHandler(), + store: limiterCache('tts_ip_limiter'), }; const userLimiterOptions = { windowMs: ttsUserWindowMs, max: ttsUserMax, handler: createTTSHandler(false), + store: limiterCache('tts_user_limiter'), keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for TTS rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'tts_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'tts_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const ttsIpLimiter = rateLimit(ipLimiterOptions); const ttsUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js index d9049f898e7..0ec4bde8d1e 100644 --- a/api/server/middleware/limiters/uploadLimiters.js +++ b/api/server/middleware/limiters/uploadLimiters.js @@ -1,16 +1,14 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); const getEnvironmentVariables = () => { const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100; const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15; const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50; const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15; + const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE; const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000; const fileUploadIpMax = FILE_UPLOAD_IP_MAX; @@ -27,6 +25,7 @@ const getEnvironmentVariables = () => { fileUploadUserWindowMs, fileUploadUserMax, fileUploadUserWindowInMinutes, + fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE, }; }; @@ -36,6 +35,7 @@ const createFileUploadHandler = (ip = true) => { fileUploadIpWindowInMinutes, fileUploadUserMax, fileUploadUserWindowInMinutes, + fileUploadViolationScore, } = getEnvironmentVariables(); return async (req, res) => { @@ -47,7 +47,7 @@ const createFileUploadHandler = (ip = true) => { windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes, }; - await logViolation(req, res, type, errorMessage); + await logViolation(req, res, type, errorMessage, fileUploadViolationScore); res.status(429).json({ message: 'Too many file upload requests. Try again later' }); }; }; @@ -60,6 +60,7 @@ const createFileLimiters = () => { windowMs: fileUploadIpWindowMs, max: fileUploadIpMax, handler: createFileUploadHandler(), + store: limiterCache('file_upload_ip_limiter'), }; const userLimiterOptions = { @@ -69,23 +70,9 @@ const createFileLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('file_upload_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for file upload rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'file_upload_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'file_upload_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const fileUploadIpLimiter = rateLimit(ipLimiterOptions); const fileUploadUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js index 73bfa2daf34..0025c041fdc 100644 --- a/api/server/middleware/limiters/verifyEmailLimiter.js +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -1,10 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { VERIFY_EMAIL_WINDOW = 2, @@ -33,17 +31,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('verify_email_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for verify email rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'verify_email_limiter:', - }); - limiterOptions.store = store; -} - const verifyEmailLimiter = rateLimit(limiterOptions); module.exports = verifyEmailLimiter; diff --git a/api/server/middleware/uaParser.js b/api/server/middleware/uaParser.js index f5b726dd3a9..d7d38f1023d 100644 --- a/api/server/middleware/uaParser.js +++ b/api/server/middleware/uaParser.js @@ -1,5 +1,6 @@ const uap = require('ua-parser-js'); -const { handleError } = require('../utils'); +const { ViolationTypes } = require('librechat-data-provider'); +const { handleError } = require('@librechat/api'); const { logViolation } = require('../../cache'); /** @@ -21,7 +22,7 @@ async function uaParser(req, res, next) { const ua = uap(req.headers['user-agent']); if (!ua.browser.name) { - const type = 'non_browser'; + const type = ViolationTypes.NON_BROWSER; await logViolation(req, res, type, { type }, score); return handleError(res, { message: 'Illegal request' }); } diff --git a/api/server/middleware/validateEndpoint.js b/api/server/middleware/validateEndpoint.js index 0eeaaeb97dc..51cf14bf091 100644 --- a/api/server/middleware/validateEndpoint.js +++ b/api/server/middleware/validateEndpoint.js @@ -1,4 +1,4 @@ -const { handleError } = require('../utils'); +const { handleError } = require('@librechat/api'); function validateEndpoint(req, res, next) { const { endpoint: _endpoint, endpointType } = req.body; diff --git a/api/server/middleware/validateModel.js b/api/server/middleware/validateModel.js index dacbb826297..35671589c64 100644 --- a/api/server/middleware/validateModel.js +++ b/api/server/middleware/validateModel.js @@ -1,6 +1,6 @@ +const { handleError } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { getModelsConfig } = require('~/server/controllers/ModelController'); -const { handleError } = require('~/server/utils'); const { logViolation } = require('~/cache'); /** * Validates the model of the request. diff --git a/api/server/routes/__tests__/static.spec.js b/api/server/routes/__tests__/static.spec.js new file mode 100644 index 00000000000..239ad7e095e --- /dev/null +++ b/api/server/routes/__tests__/static.spec.js @@ -0,0 +1,162 @@ +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const request = require('supertest'); +const zlib = require('zlib'); + +// Create test setup +const mockTestDir = path.join(__dirname, 'test-static-route'); + +// Mock the paths module to point to our test directory +jest.mock('~/config/paths', () => ({ + imageOutput: mockTestDir, +})); + +describe('Static Route Integration', () => { + let app; + let staticRoute; + let testDir; + let testImagePath; + + beforeAll(() => { + // Create a test directory and files + testDir = mockTestDir; + testImagePath = path.join(testDir, 'test-image.jpg'); + + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + + // Create a test image file + fs.writeFileSync(testImagePath, 'fake-image-data'); + + // Create a gzipped version of the test image (for gzip scanning tests) + fs.writeFileSync(testImagePath + '.gz', zlib.gzipSync('fake-image-data')); + }); + + afterAll(() => { + // Clean up test files + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + // Helper function to set up static route with specific config + const setupStaticRoute = (skipGzipScan = false) => { + if (skipGzipScan) { + delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN; + } else { + process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN = 'true'; + } + + staticRoute = require('../static'); + app.use('/images', staticRoute); + }; + + beforeEach(() => { + // Clear the module cache to get fresh imports + jest.resetModules(); + + app = express(); + + // Clear environment variables + delete process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN; + delete process.env.NODE_ENV; + }); + + describe('route functionality', () => { + it('should serve static image files', async () => { + process.env.NODE_ENV = 'production'; + setupStaticRoute(); + + const response = await request(app).get('/images/test-image.jpg').expect(200); + + expect(response.body.toString()).toBe('fake-image-data'); + }); + + it('should return 404 for non-existent files', async () => { + setupStaticRoute(); + + const response = await request(app).get('/images/nonexistent.jpg'); + expect(response.status).toBe(404); + }); + }); + + describe('cache behavior', () => { + it('should set cache headers for images in production', async () => { + process.env.NODE_ENV = 'production'; + setupStaticRoute(); + + const response = await request(app).get('/images/test-image.jpg').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + }); + + it('should not set cache headers in development', async () => { + process.env.NODE_ENV = 'development'; + setupStaticRoute(); + + const response = await request(app).get('/images/test-image.jpg').expect(200); + + // Our middleware should not set the production cache-control header in development + expect(response.headers['cache-control']).not.toBe('public, max-age=172800, s-maxage=86400'); + }); + }); + + describe('gzip compression behavior', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should serve gzipped files when gzip scanning is enabled', async () => { + setupStaticRoute(false); // Enable gzip scanning + + const response = await request(app) + .get('/images/test-image.jpg') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.body.toString()).toBe('fake-image-data'); + }); + + it('should not serve gzipped files when gzip scanning is disabled', async () => { + setupStaticRoute(true); // Disable gzip scanning + + const response = await request(app) + .get('/images/test-image.jpg') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.body.toString()).toBe('fake-image-data'); + }); + }); + + describe('path configuration', () => { + it('should use the configured imageOutput path', async () => { + setupStaticRoute(); + + const response = await request(app).get('/images/test-image.jpg').expect(200); + + expect(response.body.toString()).toBe('fake-image-data'); + }); + + it('should serve from subdirectories', async () => { + // Create a subdirectory with a file + const subDir = path.join(testDir, 'thumbs'); + fs.mkdirSync(subDir, { recursive: true }); + const thumbPath = path.join(subDir, 'thumb.jpg'); + fs.writeFileSync(thumbPath, 'thumbnail-data'); + + setupStaticRoute(); + + const response = await request(app).get('/images/thumbs/thumb.jpg').expect(200); + + expect(response.body.toString()).toBe('thumbnail-data'); + + // Clean up + fs.rmSync(subDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js new file mode 100644 index 00000000000..a669559982c --- /dev/null +++ b/api/server/routes/files/files.agents.test.js @@ -0,0 +1,282 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; + +// Mock dependencies +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn().mockResolvedValue({}), + filterFile: jest.fn(), + processFileUpload: jest.fn(), + processAgentFileUpload: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({})), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/server/services/Files/S3/crud', () => ({ + refreshS3FileUrls: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const { createFile } = require('~/models/File'); +const { createAgent } = require('~/models/Agent'); +const { getProjectByName } = require('~/models/Project'); + +// Import the router after mocks +const router = require('./files'); + +describe('File Routes - Agent Files Endpoint', () => { + let app; + let mongoServer; + let authorId; + let otherUserId; + let agentId; + let fileId1; + let fileId2; + let fileId3; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Initialize models + require('~/db/models'); + + app = express(); + app.use(express.json()); + + // Mock authentication middleware + app.use((req, res, next) => { + req.user = { id: otherUserId || 'default-user' }; + req.app = { locals: {} }; + next(); + }); + + app.use('/files', router); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Clear database + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + authorId = new mongoose.Types.ObjectId().toString(); + otherUserId = new mongoose.Types.ObjectId().toString(); + agentId = uuidv4(); + fileId1 = uuidv4(); + fileId2 = uuidv4(); + fileId3 = uuidv4(); + + // Create files + await createFile({ + user: authorId, + file_id: fileId1, + filename: 'agent-file1.txt', + filepath: `/uploads/${authorId}/${fileId1}`, + bytes: 1024, + type: 'text/plain', + }); + + await createFile({ + user: authorId, + file_id: fileId2, + filename: 'agent-file2.txt', + filepath: `/uploads/${authorId}/${fileId2}`, + bytes: 2048, + type: 'text/plain', + }); + + await createFile({ + user: otherUserId, + file_id: fileId3, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${fileId3}`, + bytes: 512, + type: 'text/plain', + }); + + // Create an agent with files attached + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2], + }, + }, + }); + + // Share the agent globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); + } + }); + + describe('GET /files/agent/:agent_id', () => { + it('should return files accessible through the agent for non-author', async () => { + const response = await request(app).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); // Only agent files, not user-owned files + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).not.toContain(fileId3); // User's own file not included + }); + + it('should return 400 when agent_id is not provided', async () => { + const response = await request(app).get('/files/agent/'); + + expect(response.status).toBe(404); // Express returns 404 for missing route parameter + }); + + it('should return empty array for non-existent agent', async () => { + const response = await request(app).get('/files/agent/non-existent-agent'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); // Empty array for non-existent agent + }); + + it('should return empty array when agent is not collaborative', async () => { + // Create a non-collaborative agent + const nonCollabAgentId = uuidv4(); + await createAgent({ + id: nonCollabAgentId, + name: 'Non-Collaborative Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: false, + tool_resources: { + file_search: { + file_ids: [fileId1], + }, + }, + }); + + // Share it globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] }); + } + + const response = await request(app).get(`/files/agent/${nonCollabAgentId}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); // Empty array when not collaborative + }); + + it('should return agent files for agent author', async () => { + // Create a new app instance with author authentication + const authorApp = express(); + authorApp.use(express.json()); + authorApp.use((req, res, next) => { + req.user = { id: authorId }; + req.app = { locals: {} }; + next(); + }); + authorApp.use('/files', router); + + const response = await request(authorApp).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); // Agent files for author + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).not.toContain(fileId3); // User's own file not included + }); + + it('should return files uploaded by other users to shared agent for author', async () => { + // Create a file uploaded by another user + const otherUserFileId = uuidv4(); + const anotherUserId = new mongoose.Types.ObjectId().toString(); + + await createFile({ + user: anotherUserId, + file_id: otherUserFileId, + filename: 'other-user-file.txt', + filepath: `/uploads/${anotherUserId}/${otherUserFileId}`, + bytes: 4096, + type: 'text/plain', + }); + + // Update agent to include the file uploaded by another user + const { updateAgent } = require('~/models/Agent'); + await updateAgent( + { id: agentId }, + { + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2, otherUserFileId], + }, + }, + }, + ); + + // Create app instance with author authentication + const authorApp = express(); + authorApp.use(express.json()); + authorApp.use((req, res, next) => { + req.user = { id: authorId }; + req.app = { locals: {} }; + next(); + }); + authorApp.use('/files', router); + + const response = await request(authorApp).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); // Including file from another user + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).toContain(otherUserFileId); // File uploaded by another user + }); + }); +}); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index bdfdca65cfe..e0b354ef6eb 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -5,6 +5,7 @@ const { Time, isUUID, CacheKeys, + Constants, FileSources, EModelEndpoint, isAgentsEndpoint, @@ -16,11 +17,12 @@ const { processDeleteRequest, processAgentFileUpload, } = require('~/server/services/Files/process'); +const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); -const { getFiles, batchUpdateFiles } = require('~/models/File'); +const { getProjectByName } = require('~/models/Project'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); @@ -50,6 +52,68 @@ router.get('/', async (req, res) => { } }); +/** + * Get files specific to an agent + * @route GET /files/agent/:agent_id + * @param {string} agent_id - The agent ID to get files for + * @returns {Promise} Array of files attached to the agent + */ +router.get('/agent/:agent_id', async (req, res) => { + try { + const { agent_id } = req.params; + const userId = req.user.id; + + if (!agent_id) { + return res.status(400).json({ error: 'Agent ID is required' }); + } + + // Get the agent to check ownership and attached files + const agent = await getAgent({ id: agent_id }); + + if (!agent) { + // No agent found, return empty array + return res.status(200).json([]); + } + + // Check if user has access to the agent + if (agent.author.toString() !== userId) { + // Non-authors need the agent to be globally shared and collaborative + const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); + + if ( + !globalProject || + !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) || + !agent.isCollaborative + ) { + return res.status(200).json([]); + } + } + + // Collect all file IDs from agent's tool resources + const agentFileIds = []; + if (agent.tool_resources) { + for (const [, resource] of Object.entries(agent.tool_resources)) { + if (resource?.file_ids && Array.isArray(resource.file_ids)) { + agentFileIds.push(...resource.file_ids); + } + } + } + + // If no files attached to agent, return empty array + if (agentFileIds.length === 0) { + return res.status(200).json([]); + } + + // Get only the files attached to this agent + const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 }); + + res.status(200).json(files); + } catch (error) { + logger.error('[/files/agent/:agent_id] Error fetching agent files:', error); + res.status(500).json({ error: 'Failed to fetch agent files' }); + } +}); + router.get('/config', async (req, res) => { try { res.status(200).json(req.app.locals.fileConfig); @@ -86,11 +150,62 @@ router.delete('/', async (req, res) => { const fileIds = files.map((file) => file.file_id); const dbFiles = await getFiles({ file_id: { $in: fileIds } }); - const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id); + + const ownedFiles = []; + const nonOwnedFiles = []; + const fileMap = new Map(); + + for (const file of dbFiles) { + fileMap.set(file.file_id, file); + if (file.user.toString() === req.user.id) { + ownedFiles.push(file); + } else { + nonOwnedFiles.push(file); + } + } + + // If all files are owned by the user, no need for further checks + if (nonOwnedFiles.length === 0) { + await processDeleteRequest({ req, files: ownedFiles }); + logger.debug( + `[/files] Files deleted successfully: ${ownedFiles + .filter((f) => f.file_id) + .map((f) => f.file_id) + .join(', ')}`, + ); + res.status(200).json({ message: 'Files deleted successfully' }); + return; + } + + // Check access for non-owned files + let authorizedFiles = [...ownedFiles]; + let unauthorizedFiles = []; + + if (req.body.agent_id && nonOwnedFiles.length > 0) { + // Batch check access for all non-owned files + const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent( + req.user.id, + nonOwnedFileIds, + req.body.agent_id, + ); + + // Separate authorized and unauthorized files + for (const file of nonOwnedFiles) { + if (accessMap.get(file.file_id)) { + authorizedFiles.push(file); + } else { + unauthorizedFiles.push(file); + } + } + } else { + // No agent context, all non-owned files are unauthorized + unauthorizedFiles = nonOwnedFiles; + } if (unauthorizedFiles.length > 0) { return res.status(403).json({ - message: 'You can only delete your own files', + message: 'You can only delete files you have access to', unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id), }); } @@ -131,10 +246,10 @@ router.delete('/', async (req, res) => { .json({ message: 'File associations removed successfully from Azure Assistant' }); } - await processDeleteRequest({ req, files: dbFiles }); + await processDeleteRequest({ req, files: authorizedFiles }); logger.debug( - `[/files] Files deleted successfully: ${files + `[/files] Files deleted successfully: ${authorizedFiles .filter((f) => f.file_id) .map((f) => f.file_id) .join(', ')}`, diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js new file mode 100644 index 00000000000..3f5ae9fc41a --- /dev/null +++ b/api/server/routes/files/files.test.js @@ -0,0 +1,302 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; + +// Mock dependencies +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn().mockResolvedValue({}), + filterFile: jest.fn(), + processFileUpload: jest.fn(), + processAgentFileUpload: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({})), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/server/services/Files/S3/crud', () => ({ + refreshS3FileUrls: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const { createFile } = require('~/models/File'); +const { createAgent } = require('~/models/Agent'); +const { getProjectByName } = require('~/models/Project'); +const { processDeleteRequest } = require('~/server/services/Files/process'); + +// Import the router after mocks +const router = require('./files'); + +describe('File Routes - Delete with Agent Access', () => { + let app; + let mongoServer; + let authorId; + let otherUserId; + let agentId; + let fileId; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Initialize models + require('~/db/models'); + + app = express(); + app.use(express.json()); + + // Mock authentication middleware + app.use((req, res, next) => { + req.user = { id: otherUserId || 'default-user' }; + req.app = { locals: {} }; + next(); + }); + + app.use('/files', router); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Clear database + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + authorId = new mongoose.Types.ObjectId().toString(); + otherUserId = new mongoose.Types.ObjectId().toString(); + fileId = uuidv4(); + + // Create a file owned by the author + await createFile({ + user: authorId, + file_id: fileId, + filename: 'test.txt', + filepath: `/uploads/${authorId}/${fileId}`, + bytes: 1024, + type: 'text/plain', + }); + + // Create an agent with the file attached + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileId], + }, + }, + }); + agentId = agent.id; + + // Share the agent globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); + } + }); + + describe('DELETE /files', () => { + it('should allow deleting files owned by the user', async () => { + // Create a file owned by the current user + const userFileId = uuidv4(); + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${userFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + files: [ + { + file_id: userFileId, + filepath: `/uploads/${otherUserId}/${userFileId}`, + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Files deleted successfully'); + expect(processDeleteRequest).toHaveBeenCalled(); + }); + + it('should prevent deleting files not owned by user without agent context', async () => { + const response = await request(app) + .delete('/files') + .send({ + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(fileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); + }); + + it('should allow deleting files accessible through shared agent', async () => { + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Files deleted successfully'); + expect(processDeleteRequest).toHaveBeenCalled(); + }); + + it('should prevent deleting files not attached to the specified agent', async () => { + // Create another file not attached to the agent + const unattachedFileId = uuidv4(); + await createFile({ + user: authorId, + file_id: unattachedFileId, + filename: 'unattached.txt', + filepath: `/uploads/${authorId}/${unattachedFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: unattachedFileId, + filepath: `/uploads/${authorId}/${unattachedFileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(unattachedFileId); + }); + + it('should handle mixed authorized and unauthorized files', async () => { + // Create a file owned by the current user + const userFileId = uuidv4(); + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${userFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + // Create an unauthorized file + const unauthorizedFileId = uuidv4(); + await createFile({ + user: authorId, + file_id: unauthorizedFileId, + filename: 'unauthorized.txt', + filepath: `/uploads/${authorId}/${unauthorizedFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, // Authorized through agent + filepath: `/uploads/${authorId}/${fileId}`, + }, + { + file_id: userFileId, // Owned by user + filepath: `/uploads/${otherUserId}/${userFileId}`, + }, + { + file_id: unauthorizedFileId, // Not authorized + filepath: `/uploads/${authorId}/${unauthorizedFileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId); + expect(response.body.unauthorizedFiles).not.toContain(fileId); + expect(response.body.unauthorizedFiles).not.toContain(userFileId); + }); + + it('should prevent deleting files when agent is not collaborative', async () => { + // Update the agent to be non-collaborative + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { isCollaborative: false }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(fileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/server/routes/memories.js b/api/server/routes/memories.js index fe520de000f..a136bc8e611 100644 --- a/api/server/routes/memories.js +++ b/api/server/routes/memories.js @@ -172,40 +172,68 @@ router.patch('/preferences', checkMemoryOptOut, async (req, res) => { /** * PATCH /memories/:key * Updates the value of an existing memory entry for the authenticated user. - * Body: { value: string } + * Body: { key?: string, value: string } * Returns 200 and { updated: true, memory: } when successful. */ router.patch('/:key', checkMemoryUpdate, async (req, res) => { - const { key } = req.params; - const { value } = req.body || {}; + const { key: urlKey } = req.params; + const { key: bodyKey, value } = req.body || {}; if (typeof value !== 'string' || value.trim() === '') { return res.status(400).json({ error: 'Value is required and must be a non-empty string.' }); } + // Use the key from the body if provided, otherwise use the key from the URL + const newKey = bodyKey || urlKey; + try { const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); const memories = await getAllUserMemories(req.user.id); - const existingMemory = memories.find((m) => m.key === key); + const existingMemory = memories.find((m) => m.key === urlKey); if (!existingMemory) { return res.status(404).json({ error: 'Memory not found.' }); } - const result = await setMemory({ - userId: req.user.id, - key, - value, - tokenCount, - }); + // If the key is changing, we need to handle it specially + if (newKey !== urlKey) { + const keyExists = memories.find((m) => m.key === newKey); + if (keyExists) { + return res.status(409).json({ error: 'Memory with this key already exists.' }); + } - if (!result.ok) { - return res.status(500).json({ error: 'Failed to update memory.' }); + const createResult = await createMemory({ + userId: req.user.id, + key: newKey, + value, + tokenCount, + }); + + if (!createResult.ok) { + return res.status(500).json({ error: 'Failed to create new memory.' }); + } + + const deleteResult = await deleteMemory({ userId: req.user.id, key: urlKey }); + if (!deleteResult.ok) { + return res.status(500).json({ error: 'Failed to delete old memory.' }); + } + } else { + // Key is not changing, just update the value + const result = await setMemory({ + userId: req.user.id, + key: newKey, + value, + tokenCount, + }); + + if (!result.ok) { + return res.status(500).json({ error: 'Failed to update memory.' }); + } } const updatedMemories = await getAllUserMemories(req.user.id); - const updatedMemory = updatedMemories.find((m) => m.key === key); + const updatedMemory = updatedMemories.find((m) => m.key === newKey); res.json({ updated: true, memory: updatedMemory }); } catch (error) { diff --git a/api/server/routes/static.js b/api/server/routes/static.js index 2db55ebebc6..952ca82fb26 100644 --- a/api/server/routes/static.js +++ b/api/server/routes/static.js @@ -1,8 +1,11 @@ const express = require('express'); const staticCache = require('../utils/staticCache'); const paths = require('~/config/paths'); +const { isEnabled } = require('~/server/utils'); + +const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN); const router = express.Router(); -router.use(staticCache(paths.imageOutput)); +router.use(staticCache(paths.imageOutput, { skipGzipScan })); module.exports = router; diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js index f3fb6f26b4e..7495ce1e2a4 100644 --- a/api/server/services/Config/getCustomConfig.js +++ b/api/server/services/Config/getCustomConfig.js @@ -1,10 +1,9 @@ const { logger } = require('@librechat/data-schemas'); -const { getUserMCPAuthMap } = require('@librechat/api'); +const { isEnabled, getUserMCPAuthMap } = require('@librechat/api'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); -const { normalizeEndpointName, isEnabled } = require('~/server/utils'); +const { normalizeEndpointName } = require('~/server/utils'); const loadCustomConfig = require('./loadCustomConfig'); const { getCachedTools } = require('./getCachedTools'); -const { findPluginAuthsByKeys } = require('~/models'); const getLogStores = require('~/cache/getLogStores'); /** @@ -55,46 +54,48 @@ const getCustomEndpointConfig = async (endpoint) => { ); }; -async function createGetMCPAuthMap() { - const customConfig = await getCustomConfig(); - const mcpServers = customConfig?.mcpServers; - const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars); - if (!hasCustomUserVars) { - return; +/** + * @param {Object} params + * @param {string} params.userId + * @param {GenericTool[]} [params.tools] + * @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys + * @returns {Promise> | undefined>} + */ +async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) { + try { + if (!tools || tools.length === 0) { + return; + } + const appTools = await getCachedTools({ + userId, + }); + return await getUserMCPAuthMap({ + tools, + userId, + appTools, + findPluginAuthsByKeys, + }); + } catch (err) { + logger.error( + `[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`, + err, + ); } +} - /** - * @param {Object} params - * @param {GenericTool[]} [params.tools] - * @param {string} params.userId - * @returns {Promise> | undefined>} - */ - return async function ({ tools, userId }) { - try { - if (!tools || tools.length === 0) { - return; - } - const appTools = await getCachedTools({ - userId, - }); - return await getUserMCPAuthMap({ - tools, - userId, - appTools, - findPluginAuthsByKeys, - }); - } catch (err) { - logger.error( - `[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`, - err, - ); - } - }; +/** + * @returns {Promise} + */ +async function hasCustomUserVars() { + const customConfig = await getCustomConfig(); + const mcpServers = customConfig?.mcpServers; + return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); } module.exports = { + getMCPAuthMap, getCustomConfig, getBalanceConfig, - createGetMCPAuthMap, + hasCustomUserVars, getCustomEndpointConfig, }; diff --git a/api/server/services/Config/loadAsyncEndpoints.js b/api/server/services/Config/loadAsyncEndpoints.js index bf19e2ea29c..b88744e9adb 100644 --- a/api/server/services/Config/loadAsyncEndpoints.js +++ b/api/server/services/Config/loadAsyncEndpoints.js @@ -22,8 +22,7 @@ async function loadAsyncEndpoints(req) { } else { /** Only attempt to load service key if GOOGLE_KEY is not provided */ const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || - path.join(__dirname, '../../..', 'data', 'auth.json'); + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json'); try { serviceKey = await loadServiceKey(serviceKeyPath); diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 6fde14b3668..b5be8ec913c 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -8,11 +8,12 @@ const { ErrorTypes, EModelEndpoint, EToolResources, + isAgentsEndpoint, replaceSpecialVars, providerEndpointMap, } = require('librechat-data-provider'); -const { getProviderConfig } = require('~/server/services/Endpoints'); const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); +const { getProviderConfig } = require('~/server/services/Endpoints'); const { processFiles } = require('~/server/services/Files/process'); const { getFiles, getToolFilesByIds } = require('~/models/File'); const { getConvoFiles } = require('~/models/Conversation'); @@ -42,7 +43,11 @@ const initializeAgent = async ({ allowedProviders, isInitialAgent = false, }) => { - if (allowedProviders.size > 0 && !allowedProviders.has(agent.provider)) { + if ( + isAgentsEndpoint(endpointOption?.endpoint) && + allowedProviders.size > 0 && + !allowedProviders.has(agent.provider) + ) { throw new Error( `{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`, ); @@ -82,6 +87,7 @@ const initializeAgent = async ({ attachments: currentFiles, tool_resources: agent.tool_resources, requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), + agentId: agent.id, }); const provider = agent.provider; @@ -180,10 +186,11 @@ const initializeAgent = async ({ return { ...agent, + tools, attachments, resendFiles, toolContextMap, - tools, + useLegacyContent: !!options.useLegacyContent, maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9, }; }; diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 4fcbe76ea68..58da9f10dd7 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -139,6 +139,9 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid ); clientOptions.modelOptions.user = req.user.id; const options = getOpenAIConfig(apiKey, clientOptions, endpoint); + if (options != null) { + options.useLegacyContent = true; + } if (!customOptions.streamRate) { return options; } @@ -156,6 +159,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid } return { + useLegacyContent: true, llmConfig: modelOptions, }; } diff --git a/api/server/services/Endpoints/google/initialize.js b/api/server/services/Endpoints/google/initialize.js index 4e56cccb3b3..75a31a8c095 100644 --- a/api/server/services/Endpoints/google/initialize.js +++ b/api/server/services/Endpoints/google/initialize.js @@ -25,7 +25,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio /** Only attempt to load service key if GOOGLE_KEY is not provided */ try { const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../../..', 'data', 'auth.json'); serviceKey = await loadServiceKey(serviceKeyPath); if (!serviceKey) { diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index e86596181ac..bfa228f3789 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -65,19 +65,20 @@ const initializeClient = async ({ const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI; /** @type {false | TAzureConfig} */ const azureConfig = isAzureOpenAI && req.app.locals[EModelEndpoint.azureOpenAI]; - + let serverless = false; if (isAzureOpenAI && azureConfig) { const { modelGroupMap, groupMap } = azureConfig; const { azureOptions, baseURL, headers = {}, - serverless, + serverless: _serverless, } = mapModelToAzureConfig({ modelName, modelGroupMap, groupMap, }); + serverless = _serverless; clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl; clientOptions.headers = resolveHeaders( @@ -143,6 +144,9 @@ const initializeClient = async ({ clientOptions = Object.assign({ modelOptions }, clientOptions); clientOptions.modelOptions.user = req.user.id; const options = getOpenAIConfig(apiKey, clientOptions); + if (options != null && serverless === true) { + options.useLegacyContent = true; + } const streamRate = clientOptions.streamRate; if (!streamRate) { return options; diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index cf65154983a..22947379c51 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -152,6 +152,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { * @param {Object} options * @param {ServerRequest} options.req * @param {Agent['tool_resources']} options.tool_resources + * @param {string} [options.agentId] - The agent ID for file access control * @param {string} apiKey * @returns {Promise<{ * files: Array<{ id: string; session_id: string; name: string }>, @@ -159,11 +160,18 @@ async function getSessionInfo(fileIdentifier, apiKey) { * }>} */ const primeFiles = async (options, apiKey) => { - const { tool_resources } = options; + const { tool_resources, req, agentId } = options; const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? []; - const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles); + const dbFiles = ( + (await getFiles( + { file_id: { $in: file_ids } }, + null, + { text: 0 }, + { userId: req?.user?.id, agentId }, + )) ?? [] + ).concat(resourceFiles); const files = []; const sessions = new Map(); diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 527fe2d514b..c2282420032 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -2,16 +2,16 @@ const { z } = require('zod'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { Time, CacheKeys, StepTypes } = require('librechat-data-provider'); -const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api'); const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents'); +const { Constants, ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider'); const { - Constants, - ContentTypes, - isAssistantsEndpoint, - convertJsonSchemaToZod, -} = require('librechat-data-provider'); -const { getMCPManager, getFlowStateManager } = require('~/config'); + sendEvent, + MCPOAuthHandler, + normalizeServerName, + convertWithResolvedRefs, +} = require('@librechat/api'); const { findToken, createToken, updateToken } = require('~/models'); +const { getMCPManager, getFlowStateManager } = require('~/config'); const { getCachedTools } = require('./Config'); const { getLogStores } = require('~/cache'); @@ -113,7 +113,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) { /** @type {LCTool} */ const { description, parameters } = toolDefinition; const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE; - let schema = convertJsonSchemaToZod(parameters, { + let schema = convertWithResolvedRefs(parameters, { allowEmptyObject: !isGoogle, transformOneOfAnyOf: true, }); diff --git a/api/server/services/initializeMCP.js b/api/server/services/initializeMCP.js index 98b87d156ee..0ed82e6ea11 100644 --- a/api/server/services/initializeMCP.js +++ b/api/server/services/initializeMCP.js @@ -44,6 +44,9 @@ async function initializeMCP(app) { await mcpManager.mapAvailableTools(toolsCopy, flowManager); await setCachedTools(toolsCopy, { isGlobal: true }); + const cache = getLogStores(CacheKeys.CONFIG_STORE); + await cache.delete(CacheKeys.TOOLS); + logger.debug('Cleared tools array cache after MCP initialization'); logger.info('MCP servers initialized successfully'); } catch (error) { logger.error('Failed to initialize MCP servers:', error); diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 9b9541cdcd1..938d97e042e 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,8 +1,5 @@ -const { Keyv } = require('keyv'); const passport = require('passport'); const session = require('express-session'); -const MemoryStore = require('memorystore')(session); -const RedisStore = require('connect-redis').default; const { setupOpenId, googleLogin, @@ -14,8 +11,9 @@ const { openIdJwtLogin, } = require('~/strategies'); const { isEnabled } = require('~/server/utils'); -const keyvRedis = require('~/cache/keyvRedis'); const { logger } = require('~/config'); +const { getLogStores } = require('~/cache'); +const { CacheKeys } = require('librechat-data-provider'); /** * @@ -51,17 +49,8 @@ const configureSocialLogins = async (app) => { secret: process.env.OPENID_SESSION_SECRET, resave: false, saveUninitialized: false, + store: getLogStores(CacheKeys.OPENID_SESSION), }; - if (isEnabled(process.env.USE_REDIS)) { - logger.debug('Using Redis for session storage in OpenID...'); - const keyv = new Keyv({ store: keyvRedis }); - const client = keyv.opts.store.client; - sessionOptions.store = new RedisStore({ client, prefix: 'openid_session' }); - } else { - sessionOptions.store = new MemoryStore({ - checkPeriod: 86400000, // prune expired entries every 24h - }); - } app.use(session(sessionOptions)); app.use(passport.session()); const config = await setupOpenId(); @@ -82,17 +71,8 @@ const configureSocialLogins = async (app) => { secret: process.env.SAML_SESSION_SECRET, resave: false, saveUninitialized: false, + store: getLogStores(CacheKeys.SAML_SESSION), }; - if (isEnabled(process.env.USE_REDIS)) { - logger.debug('Using Redis for session storage in SAML...'); - const keyv = new Keyv({ store: keyvRedis }); - const client = keyv.opts.store.client; - sessionOptions.store = new RedisStore({ client, prefix: 'saml_session' }); - } else { - sessionOptions.store = new MemoryStore({ - checkPeriod: 86400000, // prune expired entries every 24h - }); - } app.use(session(sessionOptions)); app.use(passport.session()); setupSaml(); diff --git a/api/server/utils/__tests__/staticCache.spec.js b/api/server/utils/__tests__/staticCache.spec.js new file mode 100644 index 00000000000..5d285017bd4 --- /dev/null +++ b/api/server/utils/__tests__/staticCache.spec.js @@ -0,0 +1,407 @@ +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const request = require('supertest'); +const zlib = require('zlib'); +const staticCache = require('../staticCache'); + +describe('staticCache', () => { + let app; + let testDir; + let testFile; + let indexFile; + let manifestFile; + let swFile; + + beforeAll(() => { + // Create a test directory and files + testDir = path.join(__dirname, 'test-static'); + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + + // Create test files + testFile = path.join(testDir, 'test.js'); + indexFile = path.join(testDir, 'index.html'); + manifestFile = path.join(testDir, 'manifest.json'); + swFile = path.join(testDir, 'sw.js'); + + const jsContent = 'console.log("test");'; + const htmlContent = 'Test'; + const jsonContent = '{"name": "test"}'; + const swContent = 'self.addEventListener("install", () => {});'; + + fs.writeFileSync(testFile, jsContent); + fs.writeFileSync(indexFile, htmlContent); + fs.writeFileSync(manifestFile, jsonContent); + fs.writeFileSync(swFile, swContent); + + // Create gzipped versions of some files + fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent)); + fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }'); + fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }')); + + // Create a file that only exists in gzipped form + fs.writeFileSync( + path.join(testDir, 'only-gzipped.js.gz'), + zlib.gzipSync('console.log("only gzipped");'), + ); + + // Create a subdirectory for dist/images testing + const distImagesDir = path.join(testDir, 'dist', 'images'); + fs.mkdirSync(distImagesDir, { recursive: true }); + fs.writeFileSync(path.join(distImagesDir, 'logo.png'), 'fake-png-data'); + }); + + afterAll(() => { + // Clean up test files + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + app = express(); + + // Clear environment variables + delete process.env.NODE_ENV; + delete process.env.STATIC_CACHE_S_MAX_AGE; + delete process.env.STATIC_CACHE_MAX_AGE; + }); + describe('cache headers in production', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should set standard cache headers for regular files', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + }); + + it('should set no-cache headers for index.html', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/index.html').expect(200); + + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + + it('should set no-cache headers for manifest.json', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/manifest.json').expect(200); + + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + + it('should set no-cache headers for sw.js', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/sw.js').expect(200); + + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + + it('should not set cache headers for /dist/images/ files', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/dist/images/logo.png').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=0'); + }); + + it('should set no-cache headers when noCache option is true', async () => { + app.use(staticCache(testDir, { noCache: true })); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + }); + + describe('cache headers in non-production', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + it('should not set cache headers in development', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + // Our middleware should not set cache-control in non-production + // Express static might set its own default headers + const cacheControl = response.headers['cache-control']; + expect(cacheControl).toBe('public, max-age=0'); + }); + }); + + describe('environment variable configuration', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should use custom s-maxage from environment', async () => { + process.env.STATIC_CACHE_S_MAX_AGE = '3600'; + + // Need to re-require to pick up new env vars + jest.resetModules(); + const freshStaticCache = require('../staticCache'); + + app.use(freshStaticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=3600'); + }); + + it('should use custom max-age from environment', async () => { + process.env.STATIC_CACHE_MAX_AGE = '7200'; + + // Need to re-require to pick up new env vars + jest.resetModules(); + const freshStaticCache = require('../staticCache'); + + app.use(freshStaticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=7200, s-maxage=86400'); + }); + + it('should use both custom values from environment', async () => { + process.env.STATIC_CACHE_S_MAX_AGE = '1800'; + process.env.STATIC_CACHE_MAX_AGE = '3600'; + + // Need to re-require to pick up new env vars + jest.resetModules(); + const freshStaticCache = require('../staticCache'); + + app.use(freshStaticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=3600, s-maxage=1800'); + }); + }); + + describe('express-static-gzip behavior', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should serve gzipped files when client accepts gzip encoding', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'gzip, deflate') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['content-type']).toMatch(/javascript/); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + // Content should be decompressed by supertest + expect(response.text).toBe('console.log("test");'); + }); + + it('should fall back to uncompressed files when client does not accept gzip', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'identity') + .expect(200); + + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers['content-type']).toMatch(/javascript/); + expect(response.text).toBe('console.log("test");'); + }); + + it('should serve gzipped CSS files with correct content-type', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.css') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['content-type']).toMatch(/css/); + expect(response.text).toBe('body { color: red; }'); + }); + + it('should serve uncompressed files when no gzipped version exists', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/manifest.json') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers['content-type']).toMatch(/json/); + expect(response.text).toBe('{"name": "test"}'); + }); + + it('should handle files that only exist in gzipped form', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/only-gzipped.js') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['content-type']).toMatch(/javascript/); + expect(response.text).toBe('console.log("only gzipped");'); + }); + + it('should return 404 for gzip-only files when client does not accept gzip', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/only-gzipped.js') + .set('Accept-Encoding', 'identity'); + expect(response.status).toBe(404); + }); + + it('should handle cache headers correctly for gzipped content', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(response.headers['content-type']).toMatch(/javascript/); + }); + + it('should preserve original MIME types for gzipped files', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const jsResponse = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'gzip') + .expect(200); + + const cssResponse = await request(app) + .get('/test.css') + .set('Accept-Encoding', 'gzip') + .expect(200); + + expect(jsResponse.headers['content-type']).toMatch(/javascript/); + expect(cssResponse.headers['content-type']).toMatch(/css/); + expect(jsResponse.headers['content-encoding']).toBe('gzip'); + expect(cssResponse.headers['content-encoding']).toBe('gzip'); + }); + }); + + describe('skipGzipScan option comparison', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should use express.static (no gzip) when skipGzipScan is true', async () => { + app.use(staticCache(testDir, { skipGzipScan: true })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'gzip') + .expect(200); + + // Should NOT serve gzipped version even though client accepts it + expect(response.headers['content-encoding']).toBeUndefined(); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(response.text).toBe('console.log("test");'); + }); + + it('should use expressStaticGzip when skipGzipScan is false', async () => { + app.use(staticCache(testDir)); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'gzip') + .expect(200); + + // Should serve gzipped version when client accepts it + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(response.text).toBe('console.log("test");'); + }); + }); + + describe('file serving', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should serve files correctly', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/test.js').expect(200); + + expect(response.text).toBe('console.log("test");'); + expect(response.headers['content-type']).toMatch(/javascript|text/); + }); + + it('should return 404 for non-existent files', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/nonexistent.js'); + expect(response.status).toBe(404); + }); + + it('should serve HTML files', async () => { + app.use(staticCache(testDir)); + + const response = await request(app).get('/index.html').expect(200); + + expect(response.text).toBe('Test'); + expect(response.headers['content-type']).toMatch(/html/); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should handle webmanifest files', async () => { + // Create a webmanifest file + const webmanifestFile = path.join(testDir, 'site.webmanifest'); + fs.writeFileSync(webmanifestFile, '{"name": "test app"}'); + + app.use(staticCache(testDir)); + + const response = await request(app).get('/site.webmanifest').expect(200); + + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + + // Clean up + fs.unlinkSync(webmanifestFile); + }); + + it('should handle files in subdirectories', async () => { + const subDir = path.join(testDir, 'subdir'); + fs.mkdirSync(subDir, { recursive: true }); + const subFile = path.join(subDir, 'nested.js'); + fs.writeFileSync(subFile, 'console.log("nested");'); + + app.use(staticCache(testDir)); + + const response = await request(app).get('/subdir/nested.js').expect(200); + + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(response.text).toBe('console.log("nested");'); + + // Clean up + fs.rmSync(subDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index e8852732236..ecaea856d0a 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -1,4 +1,5 @@ const path = require('path'); +const express = require('express'); const expressStaticGzip = require('express-static-gzip'); const oneDayInSeconds = 24 * 60 * 60; @@ -7,44 +8,55 @@ const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds; const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; /** - * Creates an Express static middleware with gzip compression and configurable caching + * Creates an Express static middleware with optional gzip compression and configurable caching * * @param {string} staticPath - The file system path to serve static files from * @param {Object} [options={}] - Configuration options * @param {boolean} [options.noCache=false] - If true, disables caching entirely for all files - * @returns {ReturnType} Express middleware function for serving static files + * @param {boolean} [options.skipGzipScan=false] - If true, skips expressStaticGzip middleware + * @returns {ReturnType|ReturnType} Express middleware function for serving static files */ function staticCache(staticPath, options = {}) { - const { noCache = false } = options; - return expressStaticGzip(staticPath, { - enableBrotli: false, - orderPreference: ['gz'], - setHeaders: (res, filePath) => { - if (process.env.NODE_ENV?.toLowerCase() !== 'production') { - return; - } - if (noCache) { - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - return; - } - if (filePath.includes('/dist/images/')) { - return; - } - const fileName = path.basename(filePath); + const { noCache = false, skipGzipScan = false } = options; - if ( - fileName === 'index.html' || - fileName.endsWith('.webmanifest') || - fileName === 'manifest.json' || - fileName === 'sw.js' - ) { - res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); - } else { - res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`); - } - }, - index: false, - }); + const setHeaders = (res, filePath) => { + if (process.env.NODE_ENV?.toLowerCase() !== 'production') { + return; + } + if (noCache) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + return; + } + if (filePath && filePath.includes('/dist/images/')) { + return; + } + const fileName = filePath ? path.basename(filePath) : ''; + + if ( + fileName === 'index.html' || + fileName.endsWith('.webmanifest') || + fileName === 'manifest.json' || + fileName === 'sw.js' + ) { + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); + } else { + res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`); + } + }; + + if (skipGzipScan) { + return express.static(staticPath, { + setHeaders, + index: false, + }); + } else { + return expressStaticGzip(staticPath, { + enableBrotli: false, + orderPreference: ['gz'], + setHeaders, + index: false, + }); + } } module.exports = staticCache; diff --git a/api/typedefs.js b/api/typedefs.js index c0e0dd5f463..3ad73662429 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1074,7 +1074,7 @@ /** * @exports JsonSchemaType - * @typedef {import('librechat-data-provider').JsonSchemaType} JsonSchemaType + * @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType * @memberof typedefs */ diff --git a/api/utils/tokens.js b/api/utils/tokens.js index 21608fddc6b..a8363db1822 100644 --- a/api/utils/tokens.js +++ b/api/utils/tokens.js @@ -223,6 +223,7 @@ const xAIModels = { 'grok-3-fast': 131072, 'grok-3-mini': 131072, 'grok-3-mini-fast': 131072, + 'grok-4': 256000, // 256K context }; const aggregateModels = { ...openAIModels, ...googleModels, ...bedrockModels, ...xAIModels }; diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 4a34746e8b9..8a2e8ed11dd 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -386,7 +386,7 @@ describe('matchModelName', () => { }); it('should return the closest matching key for gpt-4-1106 partial matches', () => { - expect(matchModelName('something/gpt-4-1106')).toBe('gpt-4-1106'); + expect(matchModelName('gpt-4-1106/something')).toBe('gpt-4-1106'); expect(matchModelName('gpt-4-1106-preview')).toBe('gpt-4-1106'); expect(matchModelName('gpt-4-1106-vision-preview')).toBe('gpt-4-1106'); }); @@ -589,6 +589,10 @@ describe('Grok Model Tests - Tokens', () => { expect(getModelMaxTokens('grok-3-mini-fast')).toBe(131072); }); + test('should return correct tokens for Grok 4 model', () => { + expect(getModelMaxTokens('grok-4-0709')).toBe(256000); + }); + test('should handle partial matches for Grok models with prefixes', () => { // Vision models should match before general models expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768); @@ -606,6 +610,8 @@ describe('Grok Model Tests - Tokens', () => { expect(getModelMaxTokens('xai/grok-3-fast')).toBe(131072); expect(getModelMaxTokens('xai/grok-3-mini')).toBe(131072); expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072); + // Grok 4 model + expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000); }); }); @@ -627,6 +633,8 @@ describe('Grok Model Tests - Tokens', () => { expect(matchModelName('grok-3-fast')).toBe('grok-3-fast'); expect(matchModelName('grok-3-mini')).toBe('grok-3-mini'); expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast'); + // Grok 4 model + expect(matchModelName('grok-4-0709')).toBe('grok-4'); }); test('should match Grok model variations with prefixes', () => { @@ -646,6 +654,8 @@ describe('Grok Model Tests - Tokens', () => { expect(matchModelName('xai/grok-3-fast')).toBe('grok-3-fast'); expect(matchModelName('xai/grok-3-mini')).toBe('grok-3-mini'); expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast'); + // Grok 4 model + expect(matchModelName('xai/grok-4-0709')).toBe('grok-4'); }); }); }); diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 52575e180db..a222cb29b4e 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -345,9 +345,7 @@ export type TOptions = { isContinued?: boolean; isEdited?: boolean; overrideMessages?: t.TMessage[]; - /** This value is only true when the user submits a message with "Save & Submit" for a user-created message */ - isResubmission?: boolean; - /** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */ + /** Currently only utilized when resubmitting user-created message, uses that message's currently attached files */ overrideFiles?: t.TMessage['files']; }; diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 5606b4d30ca..44838271b3b 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -1,10 +1,8 @@ import React, { useMemo } from 'react'; -import { EModelEndpoint, EToolResources } from 'librechat-data-provider'; +import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider'; import { FileSearch, ImageUpIcon, FileType2Icon, TerminalSquareIcon } from 'lucide-react'; -import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; -import { useGetEndpointsQuery } from '~/data-provider'; -import useLocalize from '~/hooks/useLocalize'; -import { OGDialog } from '~/components/ui'; +import { useLocalize, useGetAgentsConfig, useAgentCapabilities } from '~/hooks'; +import { OGDialog, OGDialogTemplate } from '~/components/ui'; interface DragDropModalProps { onOptionSelect: (option: EToolResources | undefined) => void; @@ -22,12 +20,12 @@ interface FileOption { const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragDropModalProps) => { const localize = useLocalize(); - const { data: endpointsConfig } = useGetEndpointsQuery(); - const capabilities = useMemo( - () => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [], - [endpointsConfig], - ); - + const { agentsConfig } = useGetAgentsConfig(); + /** TODO: Ephemeral Agent Capabilities + * Allow defining agent capabilities on a per-endpoint basis + * Use definition for agents endpoint for ephemeral agents + * */ + const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); const options = useMemo(() => { const _options: FileOption[] = [ { @@ -37,26 +35,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD condition: files.every((file) => file.type?.startsWith('image/')), }, ]; - for (const capability of capabilities) { - if (capability === EToolResources.file_search) { - _options.push({ - label: localize('com_ui_upload_file_search'), - value: EToolResources.file_search, - icon: , - }); - } else if (capability === EToolResources.execute_code) { - _options.push({ - label: localize('com_ui_upload_code_files'), - value: EToolResources.execute_code, - icon: , - }); - } else if (capability === EToolResources.ocr) { - _options.push({ - label: localize('com_ui_upload_ocr_text'), - value: EToolResources.ocr, - icon: , - }); - } + if (capabilities.fileSearchEnabled) { + _options.push({ + label: localize('com_ui_upload_file_search'), + value: EToolResources.file_search, + icon: , + }); + } + if (capabilities.codeEnabled) { + _options.push({ + label: localize('com_ui_upload_code_files'), + value: EToolResources.execute_code, + icon: , + }); + } + if (capabilities.ocrEnabled) { + _options.push({ + label: localize('com_ui_upload_ocr_text'), + value: EToolResources.ocr, + icon: , + }); } return _options; diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index 7919e2e8755..67d687eefdd 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { EToolResources } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import { useDeleteFilesMutation } from '~/data-provider'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; import { useFileDeletion } from '~/hooks/Files'; import FileContainer from './FileContainer'; import { logger } from '~/utils'; @@ -30,6 +32,8 @@ export default function FileRow({ isRTL?: boolean; Wrapper?: React.FC<{ children: React.ReactNode }>; }) { + const localize = useLocalize(); + const { showToast } = useToastContext(); const files = Array.from(_files?.values() ?? []).filter((file) => fileFilter ? fileFilter(file) : true, ); @@ -105,6 +109,10 @@ export default function FileRow({ ) .uniqueFiles.map((file: ExtendedFile, index: number) => { const handleDelete = () => { + showToast({ + message: localize('com_ui_deleting_file'), + status: 'info', + }); if (abortUpload && file.progress < 1) { abortUpload(); } diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index 200cf7d4029..e2781aaa5f9 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -60,7 +60,6 @@ const EditMessage = ({ conversationId, }, { - isResubmission: true, overrideFiles: message.files, }, ); diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 7bd6511cfa5..7ade7756478 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo, useRef, useEffect } from 'react'; +import React, { memo, useMemo } from 'react'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import supersub from 'remark-supersub'; @@ -7,167 +7,16 @@ import { useRecoilValue } from 'recoil'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import remarkDirective from 'remark-directive'; -import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { Pluggable } from 'unified'; -import { - useToastContext, - ArtifactProvider, - CodeBlockProvider, - useCodeBlockContext, -} from '~/Providers'; import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation'; import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact'; -import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils'; -import CodeBlock from '~/components/Messages/Content/CodeBlock'; -import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; +import MarkdownErrorBoundary from './MarkdownErrorBoundary'; +import { langSubset, preprocessLaTeX } from '~/utils'; import { unicodeCitation } from '~/components/Web'; -import { useFileDownload } from '~/data-provider'; -import useLocalize from '~/hooks/useLocalize'; +import { code, a, p } from './MarkdownComponents'; import store from '~/store'; -type TCodeProps = { - inline?: boolean; - className?: string; - children: React.ReactNode; -}; - -export const code: React.ElementType = memo(({ className, children }: TCodeProps) => { - const canRunCode = useHasAccess({ - permissionType: PermissionTypes.RUN_CODE, - permission: Permissions.USE, - }); - const match = /language-(\w+)/.exec(className ?? ''); - const lang = match && match[1]; - const isMath = lang === 'math'; - const isSingleLine = typeof children === 'string' && children.split('\n').length === 1; - - const { getNextIndex, resetCounter } = useCodeBlockContext(); - const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current; - - useEffect(() => { - resetCounter(); - }, [children, resetCounter]); - - if (isMath) { - return <>{children}; - } else if (isSingleLine) { - return ( - - {children} - - ); - } else { - return ( - - ); - } -}); - -export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => { - const match = /language-(\w+)/.exec(className ?? ''); - const lang = match && match[1]; - - if (lang === 'math') { - return children; - } else if (typeof children === 'string' && children.split('\n').length === 1) { - return ( - - {children} - - ); - } else { - return ; - } -}); - -type TAnchorProps = { - href: string; - children: React.ReactNode; -}; - -export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { - const user = useRecoilValue(store.user); - const { showToast } = useToastContext(); - const localize = useLocalize(); - - const { - file_id = '', - filename = '', - filepath, - } = useMemo(() => { - const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`); - const match = href.match(pattern); - if (match && match[0]) { - const path = match[0]; - const parts = path.split('/'); - const name = parts.pop(); - const file_id = parts.pop(); - return { file_id, filename: name, filepath: path }; - } - return { file_id: '', filename: '', filepath: '' }; - }, [user?.id, href]); - - const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id); - const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' }; - - if (!file_id || !filename) { - return ( - - {children} - - ); - } - - const handleDownload = async (event: React.MouseEvent) => { - event.preventDefault(); - try { - const stream = await downloadFile(); - if (stream.data == null || stream.data === '') { - console.error('Error downloading file: No data found'); - showToast({ - status: 'error', - message: localize('com_ui_download_error'), - }); - return; - } - const link = document.createElement('a'); - link.href = stream.data; - link.setAttribute('download', filename); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(stream.data); - } catch (error) { - console.error('Error downloading file:', error); - } - }; - - props.onClick = handleDownload; - props.target = '_blank'; - - return ( - - {children} - - ); -}); - -type TParagraphProps = { - children: React.ReactNode; -}; - -export const p: React.ElementType = memo(({ children }: TParagraphProps) => { - return

{children}

; -}); - type TContentProps = { content: string; isLatestMessage: boolean; @@ -219,31 +68,33 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { } return ( - - - + + + - {currentContent} - - - + > + {currentContent} + + + + ); }); diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx new file mode 100644 index 00000000000..e0a381ff527 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -0,0 +1,153 @@ +import React, { memo, useMemo, useRef, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useToastContext, useCodeBlockContext } from '~/Providers'; +import CodeBlock from '~/components/Messages/Content/CodeBlock'; +import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { useFileDownload } from '~/data-provider'; +import useLocalize from '~/hooks/useLocalize'; +import { handleDoubleClick } from '~/utils'; +import store from '~/store'; + +type TCodeProps = { + inline?: boolean; + className?: string; + children: React.ReactNode; +}; + +export const code: React.ElementType = memo(({ className, children }: TCodeProps) => { + const canRunCode = useHasAccess({ + permissionType: PermissionTypes.RUN_CODE, + permission: Permissions.USE, + }); + const match = /language-(\w+)/.exec(className ?? ''); + const lang = match && match[1]; + const isMath = lang === 'math'; + const isSingleLine = typeof children === 'string' && children.split('\n').length === 1; + + const { getNextIndex, resetCounter } = useCodeBlockContext(); + const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current; + + useEffect(() => { + resetCounter(); + }, [children, resetCounter]); + + if (isMath) { + return <>{children}; + } else if (isSingleLine) { + return ( + + {children} + + ); + } else { + return ( + + ); + } +}); + +export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => { + const match = /language-(\w+)/.exec(className ?? ''); + const lang = match && match[1]; + + if (lang === 'math') { + return children; + } else if (typeof children === 'string' && children.split('\n').length === 1) { + return ( + + {children} + + ); + } else { + return ; + } +}); + +type TAnchorProps = { + href: string; + children: React.ReactNode; +}; + +export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { + const user = useRecoilValue(store.user); + const { showToast } = useToastContext(); + const localize = useLocalize(); + + const { + file_id = '', + filename = '', + filepath, + } = useMemo(() => { + const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`); + const match = href.match(pattern); + if (match && match[0]) { + const path = match[0]; + const parts = path.split('/'); + const name = parts.pop(); + const file_id = parts.pop(); + return { file_id, filename: name, filepath: path }; + } + return { file_id: '', filename: '', filepath: '' }; + }, [user?.id, href]); + + const { refetch: downloadFile } = useFileDownload(user?.id ?? '', file_id); + const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' }; + + if (!file_id || !filename) { + return ( + + {children} + + ); + } + + const handleDownload = async (event: React.MouseEvent) => { + event.preventDefault(); + try { + const stream = await downloadFile(); + if (stream.data == null || stream.data === '') { + console.error('Error downloading file: No data found'); + showToast({ + status: 'error', + message: localize('com_ui_download_error'), + }); + return; + } + const link = document.createElement('a'); + link.href = stream.data; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(stream.data); + } catch (error) { + console.error('Error downloading file:', error); + } + }; + + props.onClick = handleDownload; + props.target = '_blank'; + + return ( + + {children} + + ); +}); + +type TParagraphProps = { + children: React.ReactNode; +}; + +export const p: React.ElementType = memo(({ children }: TParagraphProps) => { + return

{children}

; +}); diff --git a/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx b/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx new file mode 100644 index 00000000000..0342c60f8a2 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import remarkGfm from 'remark-gfm'; +import supersub from 'remark-supersub'; +import ReactMarkdown from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; +import type { PluggableList } from 'unified'; +import { code, codeNoExecution, a, p } from './MarkdownComponents'; +import { CodeBlockProvider } from '~/Providers'; +import { langSubset } from '~/utils'; + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +interface MarkdownErrorBoundaryProps { + children: React.ReactNode; + content: string; + codeExecution?: boolean; +} + +class MarkdownErrorBoundary extends React.Component< + MarkdownErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: MarkdownErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Markdown rendering error:', error, errorInfo); + } + + componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) { + if (prevProps.content !== this.props.content && this.state.hasError) { + this.setState({ hasError: false, error: undefined }); + } + } + + render() { + if (this.state.hasError) { + const { content, codeExecution = true } = this.props; + + const rehypePlugins: PluggableList = [ + [ + rehypeHighlight, + { + detect: true, + ignoreMissing: true, + subset: langSubset, + }, + ], + ]; + + return ( + + + {content} + + + ); + } + + return this.props.children; + } +} + +export default MarkdownErrorBoundary; diff --git a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx index 019783607cc..d553e6b7086 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx @@ -6,8 +6,9 @@ import supersub from 'remark-supersub'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import type { PluggableList } from 'unified'; -import { code, codeNoExecution, a, p } from './Markdown'; +import { code, codeNoExecution, a, p } from './MarkdownComponents'; import { CodeBlockProvider, ArtifactProvider } from '~/Providers'; +import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { langSubset } from '~/utils'; const MarkdownLite = memo( @@ -25,32 +26,34 @@ const MarkdownLite = memo( ]; return ( - - - + + + - {content} - - - + > + {content} + + + + ); }, ); diff --git a/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx index 7af4e9fcdde..62094142218 100644 --- a/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx +++ b/client/src/components/Chat/Messages/Content/MemoryArtifacts.tsx @@ -13,14 +13,25 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach const [isAnimating, setIsAnimating] = useState(false); const prevShowInfoRef = useRef(showInfo); - const memoryArtifacts = useMemo(() => { + const { hasErrors, memoryArtifacts } = useMemo(() => { + let hasErrors = false; const result: MemoryArtifact[] = []; - for (const attachment of attachments ?? []) { + + if (!attachments || attachments.length === 0) { + return { hasErrors, memoryArtifacts: result }; + } + + for (const attachment of attachments) { if (attachment?.[Tools.memory] != null) { result.push(attachment[Tools.memory]); + + if (!hasErrors && attachment[Tools.memory].type === 'error') { + hasErrors = true; + } } } - return result; + + return { hasErrors, memoryArtifacts: result }; }, [attachments]); useLayoutEffect(() => { @@ -75,7 +86,12 @@ export default function MemoryArtifacts({ attachments }: { attachments?: TAttach
diff --git a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx index 574c2e8f5f7..d00fac68c68 100644 --- a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx +++ b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx @@ -1,17 +1,47 @@ import type { MemoryArtifact } from 'librechat-data-provider'; +import { useMemo } from 'react'; import { useLocalize } from '~/hooks'; export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) { const localize = useLocalize(); + + const { updatedMemories, deletedMemories, errorMessages } = useMemo(() => { + const updated = memoryArtifacts.filter((art) => art.type === 'update'); + const deleted = memoryArtifacts.filter((art) => art.type === 'delete'); + const errors = memoryArtifacts.filter((art) => art.type === 'error'); + + const messages = errors.map((artifact) => { + try { + const errorData = JSON.parse(artifact.value as string); + + if (errorData.errorType === 'already_exceeded') { + return localize('com_ui_memory_already_exceeded', { + tokens: errorData.tokenCount, + }); + } else if (errorData.errorType === 'would_exceed') { + return localize('com_ui_memory_would_exceed', { + tokens: errorData.tokenCount, + }); + } else { + return localize('com_ui_memory_error'); + } + } catch { + return localize('com_ui_memory_error'); + } + }); + + return { + updatedMemories: updated, + deletedMemories: deleted, + errorMessages: messages, + }; + }, [memoryArtifacts, localize]); + if (memoryArtifacts.length === 0) { return null; } - // Group artifacts by type - const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update'); - const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete'); - - if (updatedMemories.length === 0 && deletedMemories.length === 0) { + if (updatedMemories.length === 0 && deletedMemories.length === 0 && errorMessages.length === 0) { return null; } @@ -23,8 +53,8 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor {localize('com_ui_memory_updated_items')}
- {updatedMemories.map((artifact, index) => ( -
+ {updatedMemories.map((artifact) => ( +
{artifact.key}
@@ -43,8 +73,8 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor {localize('com_ui_memory_deleted_items')}
- {deletedMemories.map((artifact, index) => ( -
+ {deletedMemories.map((artifact) => ( +
{artifact.key}
@@ -56,6 +86,24 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
)} + + {errorMessages.length > 0 && ( +
+

+ {localize('com_ui_memory_storage_full')} +

+
+ {errorMessages.map((errorMessage) => ( +
+ {errorMessage} +
+ ))} +
+
+ )}
); } diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index c63dfe31e75..75e6d6ea17c 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -85,7 +85,7 @@ const Part = memo( const isToolCall = 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); - if (isToolCall && toolCall.name === Tools.execute_code) { + if (isToolCall && toolCall.name === Tools.execute_code && toolCall.args) { return ( { - let parsedArgs: ParsedArgs | string = args; + let parsedArgs: ParsedArgs | string | undefined | null = args; try { - parsedArgs = JSON.parse(args); + parsedArgs = JSON.parse(args || ''); } catch { // console.error('Failed to parse args:', e); } if (typeof parsedArgs === 'object') { return parsedArgs; } - const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/); - const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s); + const langMatch = args?.match(/"lang"\s*:\s*"(\w+)"/); + const codeMatch = args?.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"(session_id|args)"|"\s*})/s); let code = ''; if (codeMatch) { @@ -51,7 +51,7 @@ export default function ExecuteCode({ attachments, }: { initialProgress: number; - args: string; + args?: string; output?: string; attachments?: TAttachment[]; }) { @@ -65,7 +65,7 @@ export default function ExecuteCode({ const outputRef = useRef(output); const prevShowCodeRef = useRef(showCode); - const { lang, code } = useParseArgs(args); + const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs); const progress = useProgress(initialProgress); useEffect(() => { @@ -144,7 +144,7 @@ export default function ExecuteCode({ onClick={() => setShowCode((prev) => !prev)} inProgressText={localize('com_ui_analyzing')} finishedText={localize('com_ui_analyzing_finished')} - hasInput={!!code.length} + hasInput={!!code?.length} isExpanded={showCode} />
diff --git a/client/src/components/Chat/Messages/Content/__tests__/MemoryArtifacts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/MemoryArtifacts.test.tsx new file mode 100644 index 00000000000..875750a54fd --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/MemoryArtifacts.test.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import MemoryArtifacts from '../MemoryArtifacts'; +import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; +import { Tools } from 'librechat-data-provider'; + +// Mock the localize hook +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => { + const translations: Record = { + com_ui_memory_updated: 'Updated saved memory', + com_ui_memory_error: 'Memory Error', + }; + return translations[key] || key; + }, +})); + +// Mock the MemoryInfo component +jest.mock('../MemoryInfo', () => ({ + __esModule: true, + default: ({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) => ( +
+ {memoryArtifacts.map((artifact, index) => ( +
+ {artifact.type}: {artifact.key} +
+ ))} +
+ ), +})); + +describe('MemoryArtifacts', () => { + const createMemoryAttachment = (type: 'update' | 'delete' | 'error', key: string): TAttachment => + ({ + type: Tools.memory, + [Tools.memory]: { + type, + key, + value: + type === 'error' + ? JSON.stringify({ errorType: 'exceeded', tokenCount: 100 }) + : 'test value', + } as MemoryArtifact, + }) as TAttachment; + + describe('Error State Handling', () => { + test('displays error styling when memory artifacts contain errors', () => { + const attachments = [ + createMemoryAttachment('error', 'system'), + createMemoryAttachment('update', 'memory1'), + ]; + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('text-red-500'); + expect(button).toHaveClass('hover:text-red-600'); + expect(button).toHaveClass('dark:text-red-400'); + expect(button).toHaveClass('dark:hover:text-red-500'); + }); + + test('displays normal styling when no errors present', () => { + const attachments = [ + createMemoryAttachment('update', 'memory1'), + createMemoryAttachment('delete', 'memory2'), + ]; + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('text-text-secondary-alt'); + expect(button).toHaveClass('hover:text-text-primary'); + expect(button).not.toHaveClass('text-red-500'); + }); + + test('displays error message when errors are present', () => { + const attachments = [createMemoryAttachment('error', 'system')]; + + render(); + + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + expect(screen.queryByText('Updated saved memory')).not.toBeInTheDocument(); + }); + + test('displays normal message when no errors are present', () => { + const attachments = [createMemoryAttachment('update', 'memory1')]; + + render(); + + expect(screen.getByText('Updated saved memory')).toBeInTheDocument(); + expect(screen.queryByText('Memory Error')).not.toBeInTheDocument(); + }); + }); + + describe('Memory Artifacts Filtering', () => { + test('filters and passes only memory-type attachments to MemoryInfo', () => { + const attachments = [ + createMemoryAttachment('update', 'memory1'), + { type: 'file' } as TAttachment, // Non-memory attachment + createMemoryAttachment('error', 'system'), + ]; + + render(); + + // Click to expand + fireEvent.click(screen.getByRole('button')); + + // Check that only memory artifacts are passed to MemoryInfo + expect(screen.getByTestId('memory-artifact-update')).toBeInTheDocument(); + expect(screen.getByTestId('memory-artifact-error')).toBeInTheDocument(); + }); + + test('correctly identifies multiple error artifacts', () => { + const attachments = [ + createMemoryAttachment('error', 'system1'), + createMemoryAttachment('error', 'system2'), + createMemoryAttachment('update', 'memory1'), + ]; + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('text-red-500'); + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + }); + }); + + describe('Collapse/Expand Functionality', () => { + test('toggles memory info visibility on button click', () => { + const attachments = [createMemoryAttachment('update', 'memory1')]; + + render(); + + // Initially collapsed + expect(screen.queryByTestId('memory-info')).not.toBeInTheDocument(); + + // Click to expand + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('memory-info')).toBeInTheDocument(); + + // Click to collapse + fireEvent.click(screen.getByRole('button')); + expect(screen.queryByTestId('memory-info')).not.toBeInTheDocument(); + }); + + test('updates aria-expanded attribute correctly', () => { + const attachments = [createMemoryAttachment('update', 'memory1')]; + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + describe('Edge Cases', () => { + test('handles empty attachments array', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('handles undefined attachments', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('handles attachments with no memory artifacts', () => { + const attachments = [{ type: 'file' } as TAttachment, { type: 'image' } as TAttachment]; + + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + test('handles malformed memory artifacts gracefully', () => { + const attachments = [ + { + type: Tools.memory, + [Tools.memory]: { + type: 'error', + key: 'system', + // Missing value + }, + } as TAttachment, + ]; + + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('text-red-500'); + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx new file mode 100644 index 00000000000..ba8247484c8 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx @@ -0,0 +1,267 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import MemoryInfo from '../MemoryInfo'; +import type { MemoryArtifact } from 'librechat-data-provider'; + +// Mock the localize hook +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string, params?: Record) => { + const translations: Record = { + com_ui_memory_updated_items: 'Updated Memories', + com_ui_memory_deleted_items: 'Deleted Memories', + com_ui_memory_already_exceeded: `Memory storage already full - exceeded by ${params?.tokens || 0} tokens. Delete existing memories before adding new ones.`, + com_ui_memory_would_exceed: `Cannot save - would exceed limit by ${params?.tokens || 0} tokens. Delete existing memories to make space.`, + com_ui_memory_deleted: 'This memory has been deleted', + com_ui_memory_storage_full: 'Memory Storage Full', + com_ui_memory_error: 'Memory Error', + com_ui_updated_successfully: 'Updated successfully', + com_ui_none_selected: 'None selected', + }; + return translations[key] || key; + }, +})); + +describe('MemoryInfo', () => { + const createMemoryArtifact = ( + type: 'update' | 'delete' | 'error', + key: string, + value?: string, + ): MemoryArtifact => ({ + type, + key, + value: value || `test value for ${key}`, + }); + + describe('Error Memory Display', () => { + test('displays error section when memory is already exceeded', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 150 }), + }, + ]; + + render(); + + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect( + screen.getByText( + 'Memory storage already full - exceeded by 150 tokens. Delete existing memories before adding new ones.', + ), + ).toBeInTheDocument(); + }); + + test('displays error when memory would exceed limit', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }), + }, + ]; + + render(); + + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect( + screen.getByText( + 'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.', + ), + ).toBeInTheDocument(); + }); + + test('displays multiple error messages', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system1', + value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 100 }), + }, + { + type: 'error', + key: 'system2', + value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 25 }), + }, + ]; + + render(); + + expect( + screen.getByText( + 'Memory storage already full - exceeded by 100 tokens. Delete existing memories before adding new ones.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Cannot save - would exceed limit by 25 tokens. Delete existing memories to make space.', + ), + ).toBeInTheDocument(); + }); + + test('applies correct styling to error messages', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }), + }, + ]; + + render(); + + const errorMessage = screen.getByText( + 'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.', + ); + const errorContainer = errorMessage.closest('div'); + + expect(errorContainer).toHaveClass('rounded-md'); + expect(errorContainer).toHaveClass('bg-red-50'); + expect(errorContainer).toHaveClass('p-3'); + expect(errorContainer).toHaveClass('text-sm'); + expect(errorContainer).toHaveClass('text-red-800'); + expect(errorContainer).toHaveClass('dark:bg-red-900/20'); + expect(errorContainer).toHaveClass('dark:text-red-400'); + }); + }); + + describe('Mixed Memory Types', () => { + test('displays all sections when different memory types are present', () => { + const memoryArtifacts: MemoryArtifact[] = [ + createMemoryArtifact('update', 'memory1', 'Updated content'), + createMemoryArtifact('delete', 'memory2'), + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 200 }), + }, + ]; + + render(); + + // Check all sections are present + expect(screen.getByText('Updated Memories')).toBeInTheDocument(); + expect(screen.getByText('Deleted Memories')).toBeInTheDocument(); + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + + // Check content + expect(screen.getByText('memory1')).toBeInTheDocument(); + expect(screen.getByText('Updated content')).toBeInTheDocument(); + expect(screen.getByText('memory2')).toBeInTheDocument(); + expect( + screen.getByText( + 'Cannot save - would exceed limit by 200 tokens. Delete existing memories to make space.', + ), + ).toBeInTheDocument(); + }); + + test('only displays sections with content', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'already_exceeded', tokenCount: 10 }), + }, + ]; + + render(); + + // Only error section should be present + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect(screen.queryByText('Updated Memories')).not.toBeInTheDocument(); + expect(screen.queryByText('Deleted Memories')).not.toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + test('handles empty memory artifacts array', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('handles malformed error data gracefully', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: 'invalid json', + }, + ]; + + render(); + + // Should render generic error message + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + }); + + test('handles missing value in error artifact', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + // value is undefined + } as MemoryArtifact, + ]; + + render(); + + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + }); + + test('handles unknown errorType gracefully', () => { + const memoryArtifacts: MemoryArtifact[] = [ + { + type: 'error', + key: 'system', + value: JSON.stringify({ errorType: 'unknown_type', tokenCount: 30 }), + }, + ]; + + render(); + + // Should show generic error message for unknown types + expect(screen.getByText('Memory Storage Full')).toBeInTheDocument(); + expect(screen.getByText('Memory Error')).toBeInTheDocument(); + }); + + test('returns null when no memories of any type exist', () => { + const memoryArtifacts: MemoryArtifact[] = [{ type: 'unknown' as any, key: 'test' }]; + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe('Update and Delete Memory Display', () => { + test('displays updated memories correctly', () => { + const memoryArtifacts: MemoryArtifact[] = [ + createMemoryArtifact('update', 'preferences', 'User prefers dark mode'), + createMemoryArtifact('update', 'location', 'Lives in San Francisco'), + ]; + + render(); + + expect(screen.getByText('Updated Memories')).toBeInTheDocument(); + expect(screen.getByText('preferences')).toBeInTheDocument(); + expect(screen.getByText('User prefers dark mode')).toBeInTheDocument(); + expect(screen.getByText('location')).toBeInTheDocument(); + expect(screen.getByText('Lives in San Francisco')).toBeInTheDocument(); + }); + + test('displays deleted memories correctly', () => { + const memoryArtifacts: MemoryArtifact[] = [ + createMemoryArtifact('delete', 'old_preference'), + createMemoryArtifact('delete', 'outdated_info'), + ]; + + render(); + + expect(screen.getByText('Deleted Memories')).toBeInTheDocument(); + expect(screen.getByText('old_preference')).toBeInTheDocument(); + expect(screen.getByText('outdated_info')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/Prompts/Groups/VariableForm.tsx b/client/src/components/Prompts/Groups/VariableForm.tsx index 09bdcd40da2..48ad704f873 100644 --- a/client/src/components/Prompts/Groups/VariableForm.tsx +++ b/client/src/components/Prompts/Groups/VariableForm.tsx @@ -8,8 +8,8 @@ import rehypeHighlight from 'rehype-highlight'; import { replaceSpecialVars } from 'librechat-data-provider'; import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form'; import type { TPromptGroup } from 'librechat-data-provider'; +import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents'; import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils'; -import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown'; import { TextareaAutosize, InputCombobox, Button } from '~/components/ui'; import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks'; import { PromptVariableGfm } from '../Markdown'; diff --git a/client/src/components/Prompts/PromptDetails.tsx b/client/src/components/Prompts/PromptDetails.tsx index 0bfd8e9931f..62e9d02e347 100644 --- a/client/src/components/Prompts/PromptDetails.tsx +++ b/client/src/components/Prompts/PromptDetails.tsx @@ -7,7 +7,7 @@ import supersub from 'remark-supersub'; import rehypeHighlight from 'rehype-highlight'; import { replaceSpecialVars } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; -import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown'; +import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents'; import { useLocalize, useAuthContext } from '~/hooks'; import CategoryIcon from './Groups/CategoryIcon'; import PromptVariables from './PromptVariables'; diff --git a/client/src/components/Prompts/PromptEditor.tsx b/client/src/components/Prompts/PromptEditor.tsx index f10a94c11e6..75584f8495f 100644 --- a/client/src/components/Prompts/PromptEditor.tsx +++ b/client/src/components/Prompts/PromptEditor.tsx @@ -9,7 +9,7 @@ import rehypeKatex from 'rehype-katex'; import remarkMath from 'remark-math'; import supersub from 'remark-supersub'; import ReactMarkdown from 'react-markdown'; -import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown'; +import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents'; import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd'; import { SaveIcon, CrossIcon } from '~/components/svg'; import VariablesDropdown from './VariablesDropdown'; diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index c2b621e35b3..64c038c5189 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -7,6 +7,7 @@ import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Prov import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; import Action from '~/components/SidePanel/Builder/Action'; import { ToolSelectDialog } from '~/components/Tools'; +import { useGetAgentFiles } from '~/data-provider'; import { icons } from '~/hooks/Endpoint/Icons'; import { processAgentOption } from '~/utils'; import Instructions from './Instructions'; @@ -49,6 +50,18 @@ export default function AgentConfig({ createMutation }: Pick { + const newFileMap = { ...fileMap }; + agentFiles.forEach((file) => { + if (file.file_id) { + newFileMap[file.file_id] = file; + } + }); + return newFileMap; + }, [fileMap, agentFiles]); + const { ocrEnabled, codeEnabled, @@ -74,10 +87,10 @@ export default function AgentConfig({ createMutation }: Pick { if (typeof agent === 'string') { @@ -94,10 +107,10 @@ export default function AgentConfig({ createMutation }: Pick { if (typeof agent === 'string') { @@ -114,10 +127,10 @@ export default function AgentConfig({ createMutation }: Pick { if (!agent_id) { diff --git a/client/src/components/SidePanel/Agents/ShareAgent.tsx b/client/src/components/SidePanel/Agents/ShareAgent.tsx index 43aba233d29..7f025ef248e 100644 --- a/client/src/components/SidePanel/Agents/ShareAgent.tsx +++ b/client/src/components/SidePanel/Agents/ShareAgent.tsx @@ -135,10 +135,9 @@ export default function ShareAgent({ 'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium', removeFocusOutlines, )} - aria-label={localize( - 'com_ui_share_var', - { 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') }, - )} + aria-label={localize('com_ui_share_var', { + 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'), + })} type="button" >
@@ -148,10 +147,9 @@ export default function ShareAgent({ - {localize( - 'com_ui_share_var', - { 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') }, - )} + {localize('com_ui_share_var', { + 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'), + })}
; - -export type AgentState = { - name: string | null; - description: string | null; - instructions: string | null; - artifacts?: string | null; - capabilities?: string[]; - tools?: string[]; -} | null; - -export type VersionWithId = { - id: number; - originalIndex: number; - version: VersionRecord; - isActive: boolean; -}; - -export type VersionContext = { - versions: VersionRecord[]; - versionIds: VersionWithId[]; - currentAgent: AgentState; - selectedAgentId: string; - activeVersion: VersionRecord | null; -}; - -export interface AgentWithVersions extends Agent { - capabilities?: string[]; - versions?: Array; -} - export default function VersionPanel() { const localize = useLocalize(); const { showToast } = useToast(); diff --git a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts index 61919953dd2..e0eb5f66d3f 100644 --- a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts +++ b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts @@ -1,4 +1,4 @@ -import { AgentState, VersionRecord } from './VersionPanel'; +import type { AgentState, VersionRecord } from './types'; export const isActiveVersion = ( version: VersionRecord, diff --git a/client/src/components/SidePanel/Agents/Version/types.ts b/client/src/components/SidePanel/Agents/Version/types.ts new file mode 100644 index 00000000000..210e4d32b5c --- /dev/null +++ b/client/src/components/SidePanel/Agents/Version/types.ts @@ -0,0 +1,35 @@ +export type VersionRecord = Record; + +export type AgentState = { + name: string | null; + description: string | null; + instructions: string | null; + artifacts?: string | null; + capabilities?: string[]; + tools?: string[]; +} | null; + +export type VersionWithId = { + id: number; + originalIndex: number; + version: VersionRecord; + isActive: boolean; +}; + +export type VersionContext = { + versions: VersionRecord[]; + versionIds: VersionWithId[]; + currentAgent: AgentState; + selectedAgentId: string; + activeVersion: VersionRecord | null; +}; + +export interface AgentWithVersions { + name: string; + description: string | null; + instructions: string | null; + artifacts?: string | null; + capabilities?: string[]; + tools?: string[]; + versions?: Array; +} diff --git a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx index 1670ba6f60f..3f916872cbb 100644 --- a/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryCreateDialog.tsx @@ -52,6 +52,10 @@ export default function MemoryCreateDialog({ if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { errorMessage = localize('com_ui_memory_key_exists'); } + // Check for key validation error (lowercase and underscores only) + else if (errorMessage.includes('lowercase letters and underscores')) { + errorMessage = localize('com_ui_memory_key_validation'); + } } } else if (error.message) { errorMessage = error.message; diff --git a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx index db6a0ab68e5..6793bf3d6b5 100644 --- a/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx +++ b/client/src/components/SidePanel/Memories/MemoryEditDialog.tsx @@ -44,9 +44,29 @@ export default function MemoryEditDialog({ status: 'success', }); }, - onError: () => { + onError: (error: Error) => { + let errorMessage = localize('com_ui_error'); + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as any; + if (axiosError.response?.data?.error) { + errorMessage = axiosError.response.data.error; + + // Check for duplicate key error + if (axiosError.response?.status === 409 || errorMessage.includes('already exists')) { + errorMessage = localize('com_ui_memory_key_exists'); + } + // Check for key validation error (lowercase and underscores only) + else if (errorMessage.includes('lowercase letters and underscores')) { + errorMessage = localize('com_ui_memory_key_validation'); + } + } + } else if (error.message) { + errorMessage = error.message; + } + showToast({ - message: localize('com_ui_error'), + message: errorMessage, status: 'error', }); }, diff --git a/client/src/data-provider/Files/index.ts b/client/src/data-provider/Files/index.ts index d0720956a0e..684ad1668bb 100644 --- a/client/src/data-provider/Files/index.ts +++ b/client/src/data-provider/Files/index.ts @@ -1,2 +1,2 @@ -export * from './queries'; export * from './mutations'; +export * from './queries'; diff --git a/client/src/data-provider/Files/mutations.ts b/client/src/data-provider/Files/mutations.ts index 539eb953e41..04700fe552d 100644 --- a/client/src/data-provider/Files/mutations.ts +++ b/client/src/data-provider/Files/mutations.ts @@ -9,6 +9,8 @@ import { } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; export const useUploadFileMutation = ( _options?: t.UploadMutationOptions, @@ -145,10 +147,24 @@ export const useDeleteFilesMutation = ( unknown // context > => { const queryClient = useQueryClient(); - const { onSuccess, ...options } = _options || {}; + const { showToast } = useToastContext(); + const localize = useLocalize(); + const { onSuccess, onError, ...options } = _options || {}; return useMutation([MutationKeys.fileDelete], { mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body), ...options, + onError: (error, vars, context) => { + if (error && typeof error === 'object' && 'response' in error) { + const errorWithResponse = error as { response?: { status?: number } }; + if (errorWithResponse.response?.status === 403) { + showToast({ + message: localize('com_ui_delete_not_allowed'), + status: 'error', + }); + } + } + onError?.(error, vars, context); + }, onSuccess: (data, vars, context) => { queryClient.setQueryData([QueryKeys.files], (cachefiles) => { const { files: filesDeleted } = vars; @@ -160,6 +176,12 @@ export const useDeleteFilesMutation = ( return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id)); }); + + showToast({ + message: localize('com_ui_delete_success'), + status: 'success', + }); + onSuccess?.(data, vars, context); if (vars.agent_id != null && vars.agent_id) { queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]); diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 4f5805b2692..019474263d3 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -1,6 +1,6 @@ import { useRecoilValue } from 'recoil'; -import { QueryKeys, dataService } from 'librechat-data-provider'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; import { addFileToCache } from '~/utils'; @@ -19,6 +19,24 @@ export const useGetFiles = ( }); }; +export const useGetAgentFiles = ( + agentId: string | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const queriesEnabled = useRecoilValue(store.queriesEnabled); + return useQuery( + DynamicQueryKeys.agentFiles(agentId ?? ''), + () => (agentId ? dataService.getAgentFiles(agentId) : Promise.resolve([])), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + enabled: (config?.enabled ?? true) === true && queriesEnabled && !!agentId, + }, + ); +}; + export const useGetFileConfig = ( config?: UseQueryOptions, ): QueryObserverResult => { diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 6bb35b00199..cf2f020ddfd 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -83,7 +83,6 @@ export default function useChatFunctions({ { editedContent = null, editedMessageId = null, - isResubmission = false, isRegenerate = false, isContinued = false, isEdited = false, @@ -230,7 +229,10 @@ export default function useChatFunctions({ } const responseMessageId = - editedMessageId ?? (latestMessage?.messageId ? latestMessage?.messageId + '_' : null) ?? null; + editedMessageId ?? + (latestMessage?.messageId && isRegenerate ? latestMessage?.messageId + '_' : null) ?? + null; + const initialResponse: TMessage = { sender: responseSender, text: '', @@ -307,7 +309,6 @@ export default function useChatFunctions({ isEdited: isEditOrContinue, isContinued, isRegenerate, - isResubmission, initialResponse, isTemporary, ephemeralAgent, diff --git a/client/src/hooks/Files/useDragHelpers.ts b/client/src/hooks/Files/useDragHelpers.ts index 3968e2bb038..d7ff122e701 100644 --- a/client/src/hooks/Files/useDragHelpers.ts +++ b/client/src/hooks/Files/useDragHelpers.ts @@ -10,6 +10,7 @@ import { EToolResources, AgentCapabilities, isAssistantsEndpoint, + defaultAgentCapabilities, } from 'librechat-data-provider'; import type { DropTargetMonitor } from 'react-dnd'; import type * as t from 'librechat-data-provider'; @@ -38,13 +39,13 @@ export default function useDragHelpers() { setDraggedFiles([]); }; - const isAgents = useMemo( - () => !isAssistantsEndpoint(conversation?.endpoint), + const isAssistants = useMemo( + () => isAssistantsEndpoint(conversation?.endpoint), [conversation?.endpoint], ); const { handleFiles } = useFileHandling({ - overrideEndpoint: isAgents ? EModelEndpoint.agents : undefined, + overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents, }); const [{ canDrop, isOver }, drop] = useDrop( @@ -52,18 +53,18 @@ export default function useDragHelpers() { accept: [NativeTypes.FILE], drop(item: { files: File[] }) { console.log('drop', item.files); - if (!isAgents) { + if (isAssistants) { handleFiles(item.files); return; } const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); const agentsConfig = endpointsConfig?.[EModelEndpoint.agents]; - const codeEnabled = - agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) === true; - const fileSearchEnabled = - agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) === true; - if (!codeEnabled && !fileSearchEnabled) { + const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities; + const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true; + const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true; + const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true; + if (!codeEnabled && !fileSearchEnabled && !ocrEnabled) { handleFiles(item.files); return; } diff --git a/client/src/locales/da/translation.json b/client/src/locales/da/translation.json index a8c5bc59f84..406f49a5677 100644 --- a/client/src/locales/da/translation.json +++ b/client/src/locales/da/translation.json @@ -918,7 +918,6 @@ "com_ui_versions": "Versioner", "com_ui_view_source": "Se kilde-chat", "com_ui_web_search": "Websøgning", - "com_ui_web_search_api_subtitle": "Søg på nettet efter opdateret information", "com_ui_web_search_cohere_key": "Indtast Cohere API-nøgle", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (valgfri)", "com_ui_web_search_jina_key": "Indtast Jina API-nøgle", diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 07c052e4c83..67e60309545 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -1038,7 +1038,6 @@ "com_ui_view_memory": "Erinnerung anzeigen", "com_ui_view_source": "Quell-Chat anzeigen", "com_ui_web_search": "Web-Suche\n", - "com_ui_web_search_api_subtitle": "Suche im Internet nach aktuellen Informationen", "com_ui_web_search_cohere_key": "Cohere API-Key eingeben", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)\n", "com_ui_web_search_jina_key": "Den Jina API Schlüssel eingeben", diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index de3721c779e..98c103d9c18 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -231,6 +231,7 @@ "com_endpoint_openai_reasoning_summary": "Responses API only: A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. Set to none,auto, concise, or detailed.", "com_endpoint_openai_resend": "Resend all previously attached images. Note: this can significantly increase token cost and you may experience errors with many image attachments.", "com_endpoint_openai_resend_files": "Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.", + "com_endpoint_disable_streaming": "Disable streaming responses and receive the complete response at once. Useful for models like o3 that require organization verification for streaming", "com_endpoint_openai_stop": "Up to 4 sequences where the API will stop generating further tokens.", "com_endpoint_openai_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.", "com_endpoint_openai_topp": "An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We recommend altering this or temperature but not both.", @@ -239,6 +240,7 @@ "com_endpoint_output": "Output", "com_endpoint_plug_image_detail": "Image Detail", "com_endpoint_plug_resend_files": "Resend Files", + "com_endpoint_disable_streaming_label": "Disable Streaming", "com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "Set custom instructions to include in System Message. Default: none", "com_endpoint_plug_skip_completion": "Skip Completion", "com_endpoint_plug_use_functions": "Use Functions", @@ -703,11 +705,14 @@ "com_ui_delete_mcp_error": "Failed to delete MCP server", "com_ui_delete_mcp_success": "MCP server deleted successfully", "com_ui_delete_memory": "Delete Memory", + "com_ui_delete_not_allowed": "Delete operation is not allowed", "com_ui_delete_prompt": "Delete Prompt?", "com_ui_delete_shared_link": "Delete shared link?", + "com_ui_delete_success": "Successfully deleted", "com_ui_delete_tool": "Delete Tool", "com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?", "com_ui_deleted": "Deleted", + "com_ui_deleting_file": "Deleting file...", "com_ui_descending": "Desc", "com_ui_description": "Description", "com_ui_description_placeholder": "Optional: Enter a description to display for the prompt", @@ -807,6 +812,7 @@ "com_ui_good_morning": "Good morning", "com_ui_happy_birthday": "It's my 1st birthday!", "com_ui_hide_image_details": "Hide Image Details", + "com_ui_hide_password": "Hide password", "com_ui_hide_qr": "Hide QR Code", "com_ui_high": "High", "com_ui_host": "Host", @@ -821,7 +827,7 @@ "com_ui_import_conversation_file_type_error": "Unsupported import type", "com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_success": "Conversations imported successfully", - "com_ui_include_shadcnui": "Include shadcn/ui", + "com_ui_include_shadcnui": "Include shadcn/ui components instructions", "com_ui_input": "Input", "com_ui_instructions": "Instructions", "com_ui_key": "Key", @@ -852,12 +858,17 @@ "com_ui_memories_allow_use": "Allow using Memories", "com_ui_memories_filter": "Filter memories...", "com_ui_memory": "Memory", + "com_ui_memory_already_exceeded": "Memory storage already full - exceeded by {{tokens}} tokens. Delete existing memories before adding new ones.", "com_ui_memory_created": "Memory created successfully", "com_ui_memory_deleted": "Memory deleted", "com_ui_memory_deleted_items": "Deleted Memories", + "com_ui_memory_error": "Memory Error", "com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.", + "com_ui_memory_key_validation": "Memory key must only contain lowercase letters and underscores.", + "com_ui_memory_storage_full": "Memory Storage Full", "com_ui_memory_updated": "Updated saved memory", "com_ui_memory_updated_items": "Updated Memories", + "com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.", "com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it", "com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.", "com_ui_misc": "Misc.", @@ -983,6 +994,7 @@ "com_ui_show": "Show", "com_ui_show_all": "Show All", "com_ui_show_image_details": "Show Image Details", + "com_ui_show_password": "Show password", "com_ui_show_qr": "Show QR Code", "com_ui_sign_in_to_domain": "Sign-in to {{0}}", "com_ui_simple": "Simple", @@ -1056,10 +1068,8 @@ "com_ui_web_search_jina_key": "Enter Jina API Key", "com_ui_web_search_processing": "Processing results", "com_ui_web_search_provider": "Search Provider", - "com_ui_web_search_provider_serper": "Serper API", "com_ui_web_search_provider_searxng": "SearXNG", - "com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)", - "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", + "com_ui_web_search_provider_serper": "Serper API", "com_ui_web_search_provider_serper_key": "Get your Serper API key", "com_ui_web_search_reading": "Reading results", "com_ui_web_search_reranker": "Reranker", @@ -1070,6 +1080,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key", + "com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)", + "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", "com_ui_web_searching": "Searching the web", "com_ui_web_searching_again": "Searching the web again", "com_ui_weekend_morning": "Happy weekend", @@ -1077,7 +1089,5 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", - "com_user_message": "You", - "com_ui_show_password": "Show password", - "com_ui_hide_password": "Hide password" -} + "com_user_message": "You" +} \ No newline at end of file diff --git a/client/src/locales/et/translation.json b/client/src/locales/et/translation.json index 485baa00ee0..1782d588463 100644 --- a/client/src/locales/et/translation.json +++ b/client/src/locales/et/translation.json @@ -940,7 +940,6 @@ "com_ui_versions": "Versioonid", "com_ui_view_source": "Vaata algset vestlust", "com_ui_web_search": "Veebiotsing", - "com_ui_web_search_api_subtitle": "Otsi veebist ajakohast teavet", "com_ui_web_search_cohere_key": "Sisesta Cohere API võti", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (valikuline)", "com_ui_web_search_jina_key": "Sisesta Jina API võti", diff --git a/client/src/locales/fr/translation.json b/client/src/locales/fr/translation.json index 15b50357890..4a0977bc580 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -1046,7 +1046,6 @@ "com_ui_view_memory": "Voir le Souvenir", "com_ui_view_source": "Voir le message d'origine", "com_ui_web_search": "Recherche web", - "com_ui_web_search_api_subtitle": "Rechercher des informations actualisées sur le web", "com_ui_web_search_cohere_key": "Entrez la clé API de Cohere", "com_ui_web_search_firecrawl_url": "Adresse URL de Firecrawl (optionnel)", "com_ui_web_search_jina_key": "Entrez la clé API de Jina", diff --git a/client/src/locales/he/translation.json b/client/src/locales/he/translation.json index 09df8b903d2..768b35500b6 100644 --- a/client/src/locales/he/translation.json +++ b/client/src/locales/he/translation.json @@ -1029,7 +1029,6 @@ "com_ui_view_memory": "הצג זיכרון", "com_ui_view_source": "הצג צ'אט מקורי", "com_ui_web_search": "חיפוש ברשת", - "com_ui_web_search_api_subtitle": "חפש מידע עדכני ברשת", "com_ui_web_search_cohere_key": "הכנס מפתח API של Cohere", "com_ui_web_search_firecrawl_url": "כתובת URL של ממשק ה-API של Firecrawl (אופציונלי)", "com_ui_web_search_jina_key": "הזן את מפתח ה-API של Jina", diff --git a/client/src/locales/hy/translation.json b/client/src/locales/hy/translation.json index d14c5ad2846..4c3552b3860 100644 --- a/client/src/locales/hy/translation.json +++ b/client/src/locales/hy/translation.json @@ -75,9 +75,12 @@ "com_auth_to_try_again": "նորից փորձելու համար։", "com_auth_username": "Օգտանուն (կամընտրական)", "com_auth_welcome_back": "Բարի վերադարձ", + "com_citation_more_details": "Լրացուցիչ մանրամասներ {{label}}-ի մասին", "com_citation_source": "Աղբյուր", "com_click_to_download": "(սեղմեք այստեղ ներբեռնելու համար)", "com_download_expired": "(ներբեռնելու ժամկետը սպառվել է)", + "com_download_expires": "(սեղմեք այստեղ ներբեռնելու համար – կսպառվի {{0}} հետո)", + "com_endpoint": "Endpoint", "com_endpoint_agent": "Գործակալ", "com_endpoint_agent_placeholder": "Խնդրում ենք ընտրել գործակալին", "com_endpoint_ai": "AI", @@ -95,6 +98,9 @@ "com_endpoint_config_key_for": "Մուտքագրեք API key-ը՝", "com_endpoint_config_key_google_need_to": "Դուք պետք է", "com_endpoint_config_key_google_service_account": "Ստեղծել ծառայողական օգտահաշիվ", + "com_endpoint_config_key_google_vertex_ai": "Միացնել Vertex AI-ը", + "com_endpoint_config_key_name": "Key", + "com_endpoint_config_key_never_expires": "Ձեր key-ը երբեք չի սպառվի", "com_endpoint_custom_name": "Անհատական անուն", "com_endpoint_deprecated": "Հնացած", "com_endpoint_examples": "Օրինակ", @@ -102,17 +108,21 @@ "com_endpoint_export_share": "Արտահանել/Կիսվել", "com_endpoint_message": "Հաղորդագրություն", "com_endpoint_message_new": "Հաղորդագրություն {{0}}", + "com_endpoint_my_preset": "Իմ պրեսեթը", "com_endpoint_open_menu": "Բացել մենյուն", "com_endpoint_output": "Ելք", "com_endpoint_preset": "պրեսեթ", "com_endpoint_preset_default_item": "Սկզբնական", "com_endpoint_preset_default_none": "Սկզբնական նախադրված պրեսեթը ակտիվ չէ։", + "com_endpoint_preset_import": "Պրեսեթը ներմուծվեց։", "com_endpoint_preset_name": "Պրեսեթի անուն", "com_endpoint_preset_selected": "Պրեսեթը ակտիվ է։", "com_endpoint_preset_selected_title": "Ակտիվ է։", "com_endpoint_preset_title": "Պրեսեթ", "com_endpoint_presets": "պրեսեթներ", + "com_endpoint_save_as_preset": "Պահպանել որպես պրեսեթ", "com_endpoint_temperature": "Temperature", + "com_error_files_dupe": "Հայտնաբերվել է կրկնվող ֆայլ։", "com_hide_examples": "Թաքցնել օրինակները", "com_nav_2fa": "Երկուփուլային նույնականացում (2FA)", "com_nav_account_settings": "Ակաունթի կարգավորումներ", @@ -121,7 +131,11 @@ "com_nav_balance_every": "Ամեն", "com_nav_balance_refill_amount": "Լիցքավորման գումարը՝", "com_nav_browser": "Բրաուզեր", + "com_nav_close_sidebar": "Փակել կողային վահանակը", "com_nav_commands": "Հրամաններ", + "com_nav_delete_account": "Ջնջել ակաունթը", + "com_nav_delete_account_confirm": "Ջնջե՞լ ակաունթը։ Հաստատո՞ւմ եք։", + "com_nav_enabled": "Միացված է", "com_nav_export": "Արտահանել", "com_nav_font_size_base": "Միջին", "com_nav_font_size_lg": "Մեծ", diff --git a/client/src/locales/ko/translation.json b/client/src/locales/ko/translation.json index 11fb0be7d8f..2e67b1d34ee 100644 --- a/client/src/locales/ko/translation.json +++ b/client/src/locales/ko/translation.json @@ -971,7 +971,6 @@ "com_ui_view_memory": "메모리 보기", "com_ui_view_source": "원본 채팅 보기", "com_ui_web_search": "웹 검색", - "com_ui_web_search_api_subtitle": "최신 정보를 검색하기 위해 웹 검색", "com_ui_web_search_cohere_key": "Cohere API 키 입력", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (선택 사항)", "com_ui_web_search_jina_key": "Jina API 키 입력", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index cb527f30d53..5ecf1b36e04 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -1049,7 +1049,6 @@ "com_ui_view_memory": "Skatīt atmiņu", "com_ui_view_source": "Skatīt avota sarunu", "com_ui_web_search": "Tīmekļa meklēšana", - "com_ui_web_search_api_subtitle": "Meklējiet tīmeklī aktuālo informāciju", "com_ui_web_search_cohere_key": "Ievadiet Cohere API atslēgu", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (pēc izvēles)", "com_ui_web_search_jina_key": "Ievadiet Jina API atslēgu", diff --git a/client/src/utils/map.ts b/client/src/utils/map.ts index 1fc8dd073a6..73a60687f49 100644 --- a/client/src/utils/map.ts +++ b/client/src/utils/map.ts @@ -18,7 +18,7 @@ export function mapAttachments(attachments: Array { jest.clearAllMocks(); }); @@ -40,4 +43,4 @@ jest.mock('react-i18next', () => { init: jest.fn(), }, }; -}); \ No newline at end of file +}); diff --git a/config/ban-user.js b/config/ban-user.js index 9612318aef4..485dc8b4100 100644 --- a/config/ban-user.js +++ b/config/ban-user.js @@ -1,6 +1,7 @@ const path = require('path'); const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); const { User } = require('@librechat/data-schemas').createModels(mongoose); +const { ViolationTypes } = require('librechat-data-provider'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); const banViolation = require('~/cache/banViolation'); @@ -65,7 +66,7 @@ const connect = require('./connect'); }; const errorMessage = { - type: 'concurrent', + type: ViolationTypes.CONCURRENT, violation_count: 20, user_id: user._id, prev_count: 0, diff --git a/e2e/playwright.config.a11y.ts b/e2e/playwright.config.a11y.ts index 7cf5eed9039..064a15900bb 100644 --- a/e2e/playwright.config.a11y.ts +++ b/e2e/playwright.config.a11y.ts @@ -26,6 +26,15 @@ const config: PlaywrightTestConfig = { CONCURRENT_VIOLATION_SCORE: '0', MESSAGE_VIOLATION_SCORE: '0', NON_BROWSER_VIOLATION_SCORE: '0', + FORK_VIOLATION_SCORE: '0', + IMPORT_VIOLATION_SCORE: '0', + TTS_VIOLATION_SCORE: '0', + STT_VIOLATION_SCORE: '0', + FILE_UPLOAD_VIOLATION_SCORE: '0', + RESET_PASSWORD_VIOLATION_SCORE: '0', + VERIFY_EMAIL_VIOLATION_SCORE: '0', + TOOL_CALL_VIOLATION_SCORE: '0', + CONVO_ACCESS_VIOLATION_SCORE: '0', ILLEGAL_MODEL_REQ_SCORE: '0', LOGIN_MAX: '20', LOGIN_WINDOW: '1', diff --git a/e2e/playwright.config.local.ts b/e2e/playwright.config.local.ts index b69cbd851a6..5281836ac25 100644 --- a/e2e/playwright.config.local.ts +++ b/e2e/playwright.config.local.ts @@ -26,6 +26,15 @@ const config: PlaywrightTestConfig = { CONCURRENT_VIOLATION_SCORE: '0', MESSAGE_VIOLATION_SCORE: '0', NON_BROWSER_VIOLATION_SCORE: '0', + FORK_VIOLATION_SCORE: '0', + IMPORT_VIOLATION_SCORE: '0', + TTS_VIOLATION_SCORE: '0', + STT_VIOLATION_SCORE: '0', + FILE_UPLOAD_VIOLATION_SCORE: '0', + RESET_PASSWORD_VIOLATION_SCORE: '0', + VERIFY_EMAIL_VIOLATION_SCORE: '0', + TOOL_CALL_VIOLATION_SCORE: '0', + CONVO_ACCESS_VIOLATION_SCORE: '0', ILLEGAL_MODEL_REQ_SCORE: '0', LOGIN_MAX: '20', LOGIN_WINDOW: '1', diff --git a/package-lock.json b/package-lock.json index b21c01836a3..fffb3e43146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.56", + "@librechat/agents": "^2.4.61", "@librechat/api": "*", "@librechat/data-schemas": "*", "@node-saml/passport-saml": "^5.0.0", @@ -1313,6 +1313,44 @@ } } }, + "api/node_modules/@langchain/openai": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.18.tgz", + "integrity": "sha512-CX1kOTbT5xVFNdtLjnM0GIYNf+P7oMSu+dGCFxxWRa3dZwWiuyuBXCm+dToUGxDLnsHuV1bKBtIzrY1mLq/A1Q==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^5.3.0", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.58 <0.4.0" + } + }, + "api/node_modules/@langchain/openai/node_modules/openai": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.9.0.tgz", + "integrity": "sha512-cmLC0pfqLLhBGxE4aZPyRPjydgYCncppV2ClQkKmW79hNjCvmzkfhz8rN5/YVDmjVQlFV+UsF1JIuNjNgeagyQ==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "api/node_modules/@smithy/abort-controller": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.2.tgz", @@ -19050,13 +19088,13 @@ } }, "node_modules/@langchain/langgraph": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.3.6.tgz", - "integrity": "sha512-TMRUEPb/eC5mS8XdY6gwLGX2druwFDxSWUQDXHHNsbrqhIrL3BPlw+UumjcKBQ8wvhk3gEspg4aHXGq8mAqbRA==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.3.7.tgz", + "integrity": "sha512-Cc0VUtuwFziCNqTNvD1QUNpMzhe9r8tTDIxFgpCwbF8oTNXxZ1tcd/iY9wr7gV5WhZm8DHEpZf3h4z3gFNeNMA==", "license": "MIT", "dependencies": { "@langchain/langgraph-checkpoint": "~0.0.18", - "@langchain/langgraph-sdk": "~0.0.32", + "@langchain/langgraph-sdk": "~0.0.90", "uuid": "^10.0.0", "zod": "^3.25.32" }, @@ -19102,9 +19140,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "0.0.89", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.89.tgz", - "integrity": "sha512-TFNFfhVxAljV4dFJa53otnT3Ox0uN24ZdW7AfV1rTPe4QTnonxlRGEUl3SSky1CaaVxYaHN9dJyn9zyhxr2jVQ==", + "version": "0.0.92", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-0.0.92.tgz", + "integrity": "sha512-YL3uPo4At0q96Jk1v7uPctpf/NuKYlbHuQzuS03lQDvvzkLNBmw6ZRKr8SFmgZwmiHz2CNMfBP21kmb9aq/9Ug==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.15", @@ -19114,7 +19152,8 @@ }, "peerDependencies": { "@langchain/core": ">=0.2.31 <0.4.0", - "react": "^18 || ^19" + "react": "^18 || ^19", + "react-dom": "^18 || ^19" }, "peerDependenciesMeta": { "@langchain/core": { @@ -19122,6 +19161,9 @@ }, "react": { "optional": true + }, + "react-dom": { + "optional": true } } }, @@ -19343,9 +19385,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.56", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.56.tgz", - "integrity": "sha512-LABHXnKyRawlzsXjdMKgPhVICapIoFcvqSTU4dmlB2C2/jzOehfmkFZQwvBhaIhx71obMJNUq7eJu1cftVnp4Q==", + "version": "2.4.61", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.61.tgz", + "integrity": "sha512-/05fUO+sYh4Xpj9k6UNwsKzV8UG25t3NbTCpvQdr1NWam56M2p1DWp4uqgqVZfJP2Lh4Dyprr4Erny3RaNhbBQ==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.24", @@ -19358,7 +19400,7 @@ "@langchain/langgraph": "^0.3.4", "@langchain/mistralai": "^0.2.1", "@langchain/ollama": "^0.2.3", - "@langchain/openai": "^0.5.16", + "@langchain/openai": "^0.5.18", "@langchain/xai": "^0.0.3", "cheerio": "^1.0.0", "dotenv": "^16.4.7", @@ -19894,10 +19936,27 @@ } } }, + "node_modules/@librechat/agents/node_modules/@langchain/openai": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-0.5.18.tgz", + "integrity": "sha512-CX1kOTbT5xVFNdtLjnM0GIYNf+P7oMSu+dGCFxxWRa3dZwWiuyuBXCm+dToUGxDLnsHuV1bKBtIzrY1mLq/A1Q==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^5.3.0", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.58 <0.4.0" + } + }, "node_modules/@librechat/agents/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -19916,6 +19975,27 @@ "node": ">= 14" } }, + "node_modules/@librechat/agents/node_modules/openai": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-5.9.0.tgz", + "integrity": "sha512-cmLC0pfqLLhBGxE4aZPyRPjydgYCncppV2ClQkKmW79hNjCvmzkfhz8rN5/YVDmjVQlFV+UsF1JIuNjNgeagyQ==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@librechat/agents/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -46494,7 +46574,7 @@ "typescript": "^5.0.4" }, "peerDependencies": { - "@librechat/agents": "^2.4.56", + "@librechat/agents": "^2.4.61", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.13.3", "axios": "^1.8.2", diff --git a/package.json b/package.json index 8789ee88419..24760675356 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,6 @@ "typescript-eslint": "^8.24.0" }, "overrides": { - "@langchain/openai": "^0.5.16", "axios": "1.8.2", "elliptic": "^6.6.1", "mdast-util-gfm-autolink-literal": "2.0.0", diff --git a/packages/api/jest.config.mjs b/packages/api/jest.config.mjs index a65d8d8af93..eb6be102de8 100644 --- a/packages/api/jest.config.mjs +++ b/packages/api/jest.config.mjs @@ -1,6 +1,7 @@ export default { collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], coverageReporters: ['text', 'cobertura'], testResultsProcessor: 'jest-junit', moduleNameMapper: { diff --git a/packages/api/package.json b/packages/api/package.json index d972e6a9b23..891de4c9cbf 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -69,7 +69,7 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { - "@librechat/agents": "^2.4.56", + "@librechat/agents": "^2.4.61", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.13.3", "axios": "^1.8.2", diff --git a/packages/api/src/agents/__tests__/memory.test.ts b/packages/api/src/agents/__tests__/memory.test.ts new file mode 100644 index 00000000000..21dda8b0e40 --- /dev/null +++ b/packages/api/src/agents/__tests__/memory.test.ts @@ -0,0 +1,165 @@ +import { Tools, type MemoryArtifact } from 'librechat-data-provider'; +import { createMemoryTool } from '../memory'; + +// Mock the logger +jest.mock('winston', () => ({ + createLogger: jest.fn(() => ({ + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + })), + format: { + combine: jest.fn(), + colorize: jest.fn(), + simple: jest.fn(), + }, + transports: { + Console: jest.fn(), + }, +})); + +// Mock the Tokenizer +jest.mock('~/utils', () => ({ + Tokenizer: { + getTokenCount: jest.fn((text: string) => text.length), // Simple mock: 1 char = 1 token + }, +})); + +describe('createMemoryTool', () => { + let mockSetMemory: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockSetMemory = jest.fn().mockResolvedValue({ ok: true }); + }); + + // Memory overflow tests + describe('overflow handling', () => { + it('should return error artifact when memory is already overflowing', async () => { + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + tokenLimit: 100, + totalTokens: 150, // Already over limit + }); + + // Call the underlying function directly since invoke() doesn't handle responseFormat in tests + const result = await tool.func({ key: 'test', value: 'new memory' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Memory storage exceeded. Cannot save new memories.'); + + const artifacts = result[1] as Record; + expect(artifacts[Tools.memory]).toBeDefined(); + expect(artifacts[Tools.memory].type).toBe('error'); + expect(artifacts[Tools.memory].key).toBe('system'); + + const errorData = JSON.parse(artifacts[Tools.memory].value as string); + expect(errorData).toEqual({ + errorType: 'already_exceeded', + tokenCount: 50, + totalTokens: 150, + tokenLimit: 100, + }); + + expect(mockSetMemory).not.toHaveBeenCalled(); + }); + + it('should return error artifact when new memory would exceed limit', async () => { + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + tokenLimit: 100, + totalTokens: 80, + }); + + // This would put us at 101 tokens total, exceeding the limit + const result = await tool.func({ key: 'test', value: 'This is a 20 char str' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Memory storage would exceed limit. Cannot save this memory.'); + + const artifacts = result[1] as Record; + expect(artifacts[Tools.memory]).toBeDefined(); + expect(artifacts[Tools.memory].type).toBe('error'); + expect(artifacts[Tools.memory].key).toBe('system'); + + const errorData = JSON.parse(artifacts[Tools.memory].value as string); + expect(errorData).toEqual({ + errorType: 'would_exceed', + tokenCount: 1, // Math.abs(-1) + totalTokens: 101, + tokenLimit: 100, + }); + + expect(mockSetMemory).not.toHaveBeenCalled(); + }); + + it('should successfully save memory when below limit', async () => { + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + tokenLimit: 100, + totalTokens: 50, + }); + + const result = await tool.func({ key: 'test', value: 'small memory' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Memory set for key "test" (12 tokens)'); + + const artifacts = result[1] as Record; + expect(artifacts[Tools.memory]).toBeDefined(); + expect(artifacts[Tools.memory].type).toBe('update'); + expect(artifacts[Tools.memory].key).toBe('test'); + expect(artifacts[Tools.memory].value).toBe('small memory'); + + expect(mockSetMemory).toHaveBeenCalledWith({ + userId: 'test-user', + key: 'test', + value: 'small memory', + tokenCount: 12, + }); + }); + }); + + // Basic functionality tests + describe('basic functionality', () => { + it('should validate keys when validKeys is provided', async () => { + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + validKeys: ['allowed', 'keys'], + }); + + const result = await tool.func({ key: 'invalid', value: 'some value' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Invalid key "invalid". Must be one of: allowed, keys'); + expect(result[1]).toBeUndefined(); + expect(mockSetMemory).not.toHaveBeenCalled(); + }); + + it('should handle setMemory failure', async () => { + mockSetMemory.mockResolvedValue({ ok: false }); + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + }); + + const result = await tool.func({ key: 'test', value: 'some value' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Failed to set memory for key "test"'); + expect(result[1]).toBeUndefined(); + }); + + it('should handle exceptions', async () => { + mockSetMemory.mockRejectedValue(new Error('DB error')); + const tool = createMemoryTool({ + userId: 'test-user', + setMemory: mockSetMemory, + }); + + const result = await tool.func({ key: 'test', value: 'some value' }); + expect(result).toHaveLength(2); + expect(result[0]).toBe('Error setting memory for key "test"'); + expect(result[1]).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/agents/auth.ts b/packages/api/src/agents/auth.ts index 564ef84b5aa..a5fc8826609 100644 --- a/packages/api/src/agents/auth.ts +++ b/packages/api/src/agents/auth.ts @@ -76,6 +76,11 @@ export async function getPluginAuthMap({ await Promise.all(decryptionPromises); return authMap; } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const plugins = pluginKeys?.join(', ') ?? 'all requested'; + logger.warn( + `[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${plugins}: ${message}`, + ); if (!throwError) { /** Empty objects for each plugin key on error */ return pluginKeys.reduce((acc, key) => { @@ -83,11 +88,6 @@ export async function getPluginAuthMap({ return acc; }, {} as PluginAuthMap); } - - const message = error instanceof Error ? error.message : 'Unknown error'; - logger.error( - `[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`, - ); throw error; } } diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index c9e4d140636..e0c217cd842 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -40,38 +40,38 @@ export const memoryInstructions = const getDefaultInstructions = ( validKeys?: string[], tokenLimit?: number, -) => `Use the \`set_memory\` tool to save important information about the user, but ONLY when the user has explicitly provided this information. If there is nothing to note about the user specifically, END THE TURN IMMEDIATELY. +) => `Use the \`set_memory\` tool to save important information about the user, but ONLY when the user has requested you to remember something. - The \`delete_memory\` tool should only be used in two scenarios: +The \`delete_memory\` tool should only be used in two scenarios: 1. When the user explicitly asks to forget or remove specific information 2. When updating existing memories, use the \`set_memory\` tool instead of deleting and re-adding the memory. - - ${ - validKeys && validKeys.length > 0 - ? `CRITICAL INSTRUCTION: Only the following keys are valid for storing memories: - ${validKeys.map((key) => `- ${key}`).join('\n ')}` - : 'You can use any appropriate key to store memories about the user.' - } - ${ - tokenLimit - ? `⚠️ TOKEN LIMIT: Each memory value must not exceed ${tokenLimit} tokens. Be concise and store only essential information.` - : '' - } +1. ONLY use memory tools when the user requests memory actions with phrases like: + - "Remember [that] [I]..." + - "Don't forget [that] [I]..." + - "Please remember..." + - "Store this..." + - "Forget [that] [I]..." + - "Delete the memory about..." + +2. NEVER store information just because the user mentioned it in conversation. + +3. NEVER use memory tools when the user asks you to use other tools or invoke tools in general. + +4. Memory tools are ONLY for memory requests, not for general tool usage. + +5. If the user doesn't ask you to remember or forget something, DO NOT use any memory tools. + +${validKeys && validKeys.length > 0 ? `\nVALID KEYS: ${validKeys.join(', ')}` : ''} + +${tokenLimit ? `\nTOKEN LIMIT: Maximum ${tokenLimit} tokens per memory value.` : ''} - ⚠️ WARNING ⚠️ - DO NOT STORE ANY INFORMATION UNLESS THE USER HAS EXPLICITLY PROVIDED IT. - ONLY store information the user has EXPLICITLY shared. - NEVER guess or assume user information. - ALL memory values must be factual statements about THIS specific user. - If nothing needs to be stored, DO NOT CALL any memory tools. - If you're unsure whether to store something, DO NOT store it. - If nothing needs to be stored, END THE TURN IMMEDIATELY.`; +When in doubt, and the user hasn't asked to remember or forget anything, END THE TURN IMMEDIATELY.`; /** * Creates a memory tool instance with user context */ -const createMemoryTool = ({ +export const createMemoryTool = ({ userId, setMemory, validKeys, @@ -84,6 +84,9 @@ const createMemoryTool = ({ tokenLimit?: number; totalTokens?: number; }) => { + const remainingTokens = tokenLimit ? tokenLimit - totalTokens : Infinity; + const isOverflowing = tokenLimit ? remainingTokens <= 0 : false; + return tool( async ({ key, value }) => { try { @@ -93,24 +96,48 @@ const createMemoryTool = ({ ', ', )}`, ); - return `Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`; + return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined]; } const tokenCount = Tokenizer.getTokenCount(value, 'o200k_base'); - if (tokenLimit && tokenCount > tokenLimit) { - logger.warn( - `Memory Agent failed to set memory: Value exceeds token limit. Value has ${tokenCount} tokens, but limit is ${tokenLimit}`, - ); - return `Memory value too large: ${tokenCount} tokens exceeds limit of ${tokenLimit}`; + if (isOverflowing) { + const errorArtifact: Record = { + [Tools.memory]: { + key: 'system', + type: 'error', + value: JSON.stringify({ + errorType: 'already_exceeded', + tokenCount: Math.abs(remainingTokens), + totalTokens: totalTokens, + tokenLimit: tokenLimit!, + }), + tokenCount: totalTokens, + }, + }; + return [`Memory storage exceeded. Cannot save new memories.`, errorArtifact]; } - if (tokenLimit && totalTokens + tokenCount > tokenLimit) { - const remainingCapacity = tokenLimit - totalTokens; - logger.warn( - `Memory Agent failed to set memory: Would exceed total token limit. Current usage: ${totalTokens}, new memory: ${tokenCount} tokens, limit: ${tokenLimit}`, - ); - return `Cannot add memory: would exceed token limit. Current usage: ${totalTokens}/${tokenLimit} tokens. This memory requires ${tokenCount} tokens, but only ${remainingCapacity} tokens available.`; + if (tokenLimit) { + const newTotalTokens = totalTokens + tokenCount; + const newRemainingTokens = tokenLimit - newTotalTokens; + + if (newRemainingTokens < 0) { + const errorArtifact: Record = { + [Tools.memory]: { + key: 'system', + type: 'error', + value: JSON.stringify({ + errorType: 'would_exceed', + tokenCount: Math.abs(newRemainingTokens), + totalTokens: newTotalTokens, + tokenLimit, + }), + tokenCount: totalTokens, + }, + }; + return [`Memory storage would exceed limit. Cannot save this memory.`, errorArtifact]; + } } const artifact: Record = { @@ -177,7 +204,7 @@ const createDeleteMemoryTool = ({ ', ', )}`, ); - return `Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`; + return [`Invalid key "${key}". Must be one of: ${validKeys.join(', ')}`, undefined]; } const artifact: Record = { @@ -269,7 +296,13 @@ export async function processMemory({ llmConfig?: Partial; }): Promise<(TAttachment | null)[] | undefined> { try { - const memoryTool = createMemoryTool({ userId, tokenLimit, setMemory, validKeys, totalTokens }); + const memoryTool = createMemoryTool({ + userId, + tokenLimit, + setMemory, + validKeys, + totalTokens, + }); const deleteMemoryTool = createDeleteMemoryTool({ userId, validKeys, @@ -335,6 +368,7 @@ ${memory ?? 'No existing memories'}`; thread_id: `memory-run-${conversationId}`, }, streamMode: 'values', + recursionLimit: 3, version: 'v2', } as const; diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index cdfa50243cd..3735726b84c 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -71,7 +71,12 @@ describe('primeResources', () => { tool_resources, }); - expect(mockGetFiles).toHaveBeenCalledWith({ file_id: { $in: ['ocr-file-1'] } }, {}, {}); + expect(mockGetFiles).toHaveBeenCalledWith( + { file_id: { $in: ['ocr-file-1'] } }, + {}, + {}, + { userId: undefined, agentId: undefined }, + ); expect(result.attachments).toEqual(mockOcrFiles); expect(result.tool_resources).toEqual(tool_resources); }); diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index e7af7927f56..40ed95370e7 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -10,12 +10,14 @@ import type { Request as ServerRequest } from 'express'; * @param filter - MongoDB filter query for files * @param _sortOptions - Sorting options (currently unused) * @param selectFields - Field selection options + * @param options - Additional options including userId and agentId for access control * @returns Promise resolving to array of files */ export type TGetFiles = ( filter: FilterQuery, _sortOptions: ProjectionType | null | undefined, selectFields: QueryOptions | null | undefined, + options?: { userId?: string; agentId?: string }, ) => Promise>; /** @@ -145,12 +147,14 @@ export const primeResources = async ({ requestFileSet, attachments: _attachments, tool_resources: _tool_resources, + agentId, }: { req: ServerRequest; requestFileSet: Set; attachments: Promise> | undefined; tool_resources: AgentToolResources | undefined; getFiles: TGetFiles; + agentId?: string; }): Promise<{ attachments: Array | undefined; tool_resources: AgentToolResources | undefined; @@ -205,6 +209,7 @@ export const primeResources = async ({ }, {}, {}, + { userId: req.user?.id, agentId }, ); for (const file of context) { diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index c6e4202ff69..552470dea83 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -143,7 +143,7 @@ export function getOpenAIConfig( }; configOptions.defaultQuery = { ...configOptions.defaultQuery, - 'api-version': 'preview', + 'api-version': configOptions.defaultQuery?.['api-version'] ?? 'preview', }; }; diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index 5aef8edc2be..eac8433103a 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -442,7 +442,7 @@ async function loadGoogleAuthConfig(): Promise<{ }> { /** Path from environment variable or default location */ const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE_PATH || + process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '..', '..', '..', 'api', 'data', 'auth.json'); const serviceKey = await loadServiceKey(serviceKeyPath); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0b002932400..afdd2141c7b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -2,6 +2,7 @@ export * from './mcp/manager'; export * from './mcp/oauth'; export * from './mcp/auth'; +export * from './mcp/zod'; /* Utilities */ export * from './mcp/utils'; export * from './utils'; diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index e46d59a3e22..99e59b5467b 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -590,12 +590,59 @@ export class MCPConnection extends EventEmitter { } public async isConnected(): Promise { + // First check if we're in a connected state + if (this.connectionState !== 'connected') { + return false; + } + try { + // Try ping first as it's the lightest check await this.client.ping(); return this.connectionState === 'connected'; } catch (error) { - logger.error(`${this.getLogPrefix()} Ping failed:`, error); - return false; + // Check if the error is because ping is not supported (method not found) + const pingUnsupported = + error instanceof Error && + ((error as Error)?.message.includes('-32601') || + (error as Error)?.message.includes('invalid method ping') || + (error as Error)?.message.includes('method not found')); + + if (!pingUnsupported) { + logger.error(`${this.getLogPrefix()} Ping failed:`, error); + return false; + } + + // Ping is not supported by this server, try an alternative verification + logger.debug( + `${this.getLogPrefix()} Server does not support ping method, verifying connection with capabilities`, + ); + + try { + // Get server capabilities to verify connection is truly active + const capabilities = this.client.getServerCapabilities(); + + // If we have capabilities, try calling a supported method to verify connection + if (capabilities?.tools) { + await this.client.listTools(); + return this.connectionState === 'connected'; + } else if (capabilities?.resources) { + await this.client.listResources(); + return this.connectionState === 'connected'; + } else if (capabilities?.prompts) { + await this.client.listPrompts(); + return this.connectionState === 'connected'; + } else { + // No capabilities to test, but we're in connected state and initialization succeeded + logger.debug( + `${this.getLogPrefix()} No capabilities to test, assuming connected based on state`, + ); + return this.connectionState === 'connected'; + } + } catch (capabilityError) { + // If capability check fails, the connection is likely broken + logger.error(`${this.getLogPrefix()} Connection verification failed:`, capabilityError); + return false; + } } } diff --git a/packages/api/src/mcp/manager.ts b/packages/api/src/mcp/manager.ts index 8b51b3cf8a5..b4a6a71a1f6 100644 --- a/packages/api/src/mcp/manager.ts +++ b/packages/api/src/mcp/manager.ts @@ -1,11 +1,12 @@ import { logger } from '@librechat/data-schemas'; import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; -import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js'; -import type { JsonSchemaType, TUser } from 'librechat-data-provider'; +import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { TokenMethods } from '@librechat/data-schemas'; -import type { FlowStateManager } from '~/flow/manager'; +import type { TUser } from 'librechat-data-provider'; import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types'; +import type { FlowStateManager } from '~/flow/manager'; +import type { JsonSchemaType } from '~/types/zod'; import type { FlowMetadata } from '~/flow/types'; import type * as t from './types'; import { CONSTANTS, isSystemUserId } from './enum'; @@ -892,7 +893,7 @@ export class MCPManager { this.updateUserLastActivity(userId); } this.checkIdleConnections(); - return formatToolContent(result, provider); + return formatToolContent(result as t.MCPToolCallResponse, provider); } catch (error) { // Log with context and re-throw or handle as needed logger.error(`${logPrefix}[${toolName}] Tool call failed`, error); diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index d95251eecca..7fc72e18c2f 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -7,8 +7,9 @@ import { WebSocketOptionsSchema, StreamableHTTPOptionsSchema, } from 'librechat-data-provider'; -import type { JsonSchemaType, TPlugin } from 'librechat-data-provider'; import type * as t from '@modelcontextprotocol/sdk/types.js'; +import type { TPlugin } from 'librechat-data-provider'; +import type { JsonSchemaType } from '~/types/zod'; export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; diff --git a/packages/data-provider/src/zod.spec.ts b/packages/api/src/mcp/zod.spec.ts similarity index 68% rename from packages/data-provider/src/zod.spec.ts rename to packages/api/src/mcp/zod.spec.ts index 6da3a4f241c..c1ab8c902f3 100644 --- a/packages/data-provider/src/zod.spec.ts +++ b/packages/api/src/mcp/zod.spec.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // zod.spec.ts import { z } from 'zod'; -import { convertJsonSchemaToZod } from './zod'; -import type { JsonSchemaType } from './zod'; +import type { JsonSchemaType } from '~/types'; +import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from './zod'; describe('convertJsonSchemaToZod', () => { describe('primitive types', () => { @@ -10,7 +10,7 @@ describe('convertJsonSchemaToZod', () => { const schema: JsonSchemaType = { type: 'string', }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse('test')).toBe('test'); expect(() => zodSchema?.parse(123)).toThrow(); @@ -21,7 +21,7 @@ describe('convertJsonSchemaToZod', () => { type: 'string', enum: ['foo', 'bar', 'baz'], }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse('foo')).toBe('foo'); expect(() => zodSchema?.parse('invalid')).toThrow(); @@ -31,7 +31,7 @@ describe('convertJsonSchemaToZod', () => { const schema: JsonSchemaType = { type: 'number', }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse(123)).toBe(123); expect(() => zodSchema?.parse('123')).toThrow(); @@ -41,7 +41,7 @@ describe('convertJsonSchemaToZod', () => { const schema: JsonSchemaType = { type: 'boolean', }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse(true)).toBe(true); expect(() => zodSchema?.parse('true')).toThrow(); @@ -54,7 +54,7 @@ describe('convertJsonSchemaToZod', () => { type: 'array', items: { type: 'string' }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); expect(() => zodSchema?.parse(['a', 123, 'c'])).toThrow(); @@ -65,7 +65,7 @@ describe('convertJsonSchemaToZod', () => { type: 'array', items: { type: 'number' }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse([1, 2, 3])).toEqual([1, 2, 3]); expect(() => zodSchema?.parse([1, '2', 3])).toThrow(); @@ -81,7 +81,7 @@ describe('convertJsonSchemaToZod', () => { age: { type: 'number' }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 }); expect(() => zodSchema?.parse({ name: 123, age: 30 })).toThrow(); @@ -96,7 +96,7 @@ describe('convertJsonSchemaToZod', () => { }, required: ['name'], }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' }); expect(() => zodSchema?.parse({})).toThrow(); @@ -117,7 +117,7 @@ describe('convertJsonSchemaToZod', () => { }, required: ['user'], }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse({ user: { name: 'John', age: 30 } })).toEqual({ user: { name: 'John', age: 30 }, @@ -135,7 +135,7 @@ describe('convertJsonSchemaToZod', () => { }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse({ names: ['John', 'Jane'] })).toEqual({ names: ['John', 'Jane'] }); expect(() => zodSchema?.parse({ names: ['John', 123] })).toThrow(); @@ -148,7 +148,7 @@ describe('convertJsonSchemaToZod', () => { type: 'object', properties: {}, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse({})).toEqual({}); }); @@ -157,7 +157,7 @@ describe('convertJsonSchemaToZod', () => { const schema = { type: 'invalid', } as unknown as JsonSchemaType; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse('anything')).toBe('anything'); expect(zodSchema?.parse(123)).toBe(123); @@ -168,7 +168,7 @@ describe('convertJsonSchemaToZod', () => { type: 'string', enum: [], }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.parse('test')).toBe('test'); }); @@ -208,7 +208,7 @@ describe('convertJsonSchemaToZod', () => { required: ['id', 'user'], }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); const validData = { id: 1, @@ -254,7 +254,7 @@ describe('convertJsonSchemaToZod', () => { name: { type: 'string' }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); expect(zodSchema?.description).toBe('A test schema description'); }); @@ -272,7 +272,7 @@ describe('convertJsonSchemaToZod', () => { }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); const shape = (zodSchema as z.ZodObject).shape; expect(shape.name.description).toBe("The user's name"); @@ -307,7 +307,7 @@ describe('convertJsonSchemaToZod', () => { }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); // Type assertions for better type safety const shape = zodSchema instanceof z.ZodObject ? zodSchema.shape : {}; @@ -352,7 +352,7 @@ describe('convertJsonSchemaToZod', () => { }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); const shape = (zodSchema as z.ZodObject).shape; expect(shape.tags.description).toBe('User tags'); @@ -375,7 +375,7 @@ describe('convertJsonSchemaToZod', () => { }, }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); const shape = (zodSchema as z.ZodObject).shape; expect(shape.role.description).toBe('User role in the system'); @@ -435,7 +435,7 @@ describe('convertJsonSchemaToZod', () => { }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); // Test top-level description expect(zodSchema?.description).toBe('User profile configuration'); @@ -476,7 +476,7 @@ describe('convertJsonSchemaToZod', () => { }, additionalProperties: true, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); // Should accept the defined property expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' }); @@ -501,7 +501,7 @@ describe('convertJsonSchemaToZod', () => { }, additionalProperties: { type: 'number' }, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); // Should accept the defined property expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' }); @@ -527,7 +527,7 @@ describe('convertJsonSchemaToZod', () => { }, additionalProperties: false, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); // Should accept the defined properties expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 }); @@ -544,7 +544,7 @@ describe('convertJsonSchemaToZod', () => { age: { type: 'number' }, }, }; - const zodSchemaWithoutAdditionalProps = convertJsonSchemaToZod(schemaWithoutAdditionalProps); + const zodSchemaWithoutAdditionalProps = convertWithResolvedRefs(schemaWithoutAdditionalProps); expect(zodSchemaWithoutAdditionalProps?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', @@ -580,7 +580,7 @@ describe('convertJsonSchemaToZod', () => { }, additionalProperties: false, }; - const zodSchema = convertJsonSchemaToZod(schema); + const zodSchema = convertWithResolvedRefs(schema); const validData = { user: { @@ -625,7 +625,7 @@ describe('convertJsonSchemaToZod', () => { ]; emptyObjectSchemas.forEach((schema) => { - expect(convertJsonSchemaToZod(schema, { allowEmptyObject: false })).toBeUndefined(); + expect(convertWithResolvedRefs(schema, { allowEmptyObject: false })).toBeUndefined(); }); }); @@ -636,7 +636,7 @@ describe('convertJsonSchemaToZod', () => { ]; emptyObjectSchemas.forEach((schema) => { - const result = convertJsonSchemaToZod(schema, { allowEmptyObject: true }); + const result = convertWithResolvedRefs(schema, { allowEmptyObject: true }); expect(result).toBeDefined(); expect(result instanceof z.ZodObject).toBeTruthy(); }); @@ -649,7 +649,7 @@ describe('convertJsonSchemaToZod', () => { ]; emptyObjectSchemas.forEach((schema) => { - const result = convertJsonSchemaToZod(schema); + const result = convertWithResolvedRefs(schema); expect(result).toBeDefined(); expect(result instanceof z.ZodObject).toBeTruthy(); }); @@ -663,8 +663,8 @@ describe('convertJsonSchemaToZod', () => { }, }; - const resultWithFlag = convertJsonSchemaToZod(schema, { allowEmptyObject: false }); - const resultWithoutFlag = convertJsonSchemaToZod(schema); + const resultWithFlag = convertWithResolvedRefs(schema, { allowEmptyObject: false }); + const resultWithoutFlag = convertWithResolvedRefs(schema); expect(resultWithFlag).toBeDefined(); expect(resultWithoutFlag).toBeDefined(); @@ -690,7 +690,7 @@ describe('convertJsonSchemaToZod', () => { }; // Convert with dropFields option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { dropFields: ['anyOf', 'oneOf'], }); @@ -731,7 +731,7 @@ describe('convertJsonSchemaToZod', () => { }; // Convert with dropFields option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { dropFields: ['anyOf', 'oneOf'], }); @@ -769,7 +769,7 @@ describe('convertJsonSchemaToZod', () => { }; // Convert with dropFields option for fields that don't exist - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { dropFields: ['anyOf', 'oneOf', 'nonExistentField'], }); @@ -811,7 +811,7 @@ describe('convertJsonSchemaToZod', () => { }; // Convert with dropFields option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { dropFields: ['anyOf', 'oneOf'], }); @@ -844,14 +844,14 @@ describe('convertJsonSchemaToZod', () => { }; // Test with allowEmptyObject: false - const result1 = convertJsonSchemaToZod(schema, { + const result1 = convertWithResolvedRefs(schema, { allowEmptyObject: false, dropFields: ['anyOf'], }); expect(result1).toBeUndefined(); // Test with allowEmptyObject: true - const result2 = convertJsonSchemaToZod(schema, { + const result2 = convertWithResolvedRefs(schema, { allowEmptyObject: true, dropFields: ['anyOf'], }); @@ -870,7 +870,7 @@ describe('convertJsonSchemaToZod', () => { } as JsonSchemaType & { oneOf?: any }; // Convert with transformOneOfAnyOf option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, }); @@ -889,7 +889,7 @@ describe('convertJsonSchemaToZod', () => { } as JsonSchemaType & { anyOf?: any }; // Convert with transformOneOfAnyOf option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, }); @@ -925,7 +925,7 @@ describe('convertJsonSchemaToZod', () => { } as JsonSchemaType & { oneOf?: any }; // Convert with transformOneOfAnyOf option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, }); @@ -949,7 +949,7 @@ describe('convertJsonSchemaToZod', () => { } as JsonSchemaType & { oneOf?: any }; // Convert with transformOneOfAnyOf option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, }); @@ -1008,7 +1008,7 @@ describe('convertJsonSchemaToZod', () => { }; // Convert with transformOneOfAnyOf option - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, }); @@ -1072,7 +1072,7 @@ describe('convertJsonSchemaToZod', () => { } as JsonSchemaType & { oneOf?: any; deprecated?: boolean }; // Convert with both options - const zodSchema = convertJsonSchemaToZod(schema, { + const zodSchema = convertWithResolvedRefs(schema, { transformOneOfAnyOf: true, dropFields: ['deprecated'], }); @@ -1083,4 +1083,406 @@ describe('convertJsonSchemaToZod', () => { expect(() => zodSchema?.parse(true)).toThrow(); }); }); + + describe('additionalProperties with anyOf/oneOf and allowEmptyObject', () => { + it('should handle anyOf with object containing only additionalProperties when allowEmptyObject is false', () => { + const schema: JsonSchemaType & { anyOf?: any } = { + type: 'object', + properties: { + filter: { + description: 'Filter field', + anyOf: [ + { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + _icontains: { type: 'string' }, + }, + }, + }, + { + type: 'null', + }, + ], + } as JsonSchemaType & { anyOf?: any }, + }, + }; + + const zodSchema = convertWithResolvedRefs(schema, { + allowEmptyObject: false, + transformOneOfAnyOf: true, + }); + + expect(zodSchema).toBeDefined(); + + const testData = { + filter: { + title: { + _icontains: 'Pirate', + }, + }, + }; + + const result = zodSchema?.parse(testData); + expect(result).toEqual(testData); + expect(result?.filter).toBeDefined(); + expect(result?.filter?.title?._icontains).toBe('Pirate'); + }); + + it('should not treat objects with additionalProperties as empty', () => { + const schema: JsonSchemaType = { + type: 'object', + additionalProperties: { + type: 'string', + }, + }; + + const zodSchemaWithoutAllow = convertWithResolvedRefs(schema, { + allowEmptyObject: false, + }); + + // Should not return undefined because it has additionalProperties + expect(zodSchemaWithoutAllow).toBeDefined(); + + const testData = { + customField: 'value', + }; + + expect(zodSchemaWithoutAllow?.parse(testData)).toEqual(testData); + }); + + it('should handle oneOf with object containing only additionalProperties', () => { + const schema: JsonSchemaType & { oneOf?: any } = { + type: 'object', + properties: {}, + oneOf: [ + { + type: 'object', + additionalProperties: true, + }, + { + type: 'object', + properties: { + specificField: { type: 'string' }, + }, + }, + ], + }; + + const zodSchema = convertWithResolvedRefs(schema, { + allowEmptyObject: false, + transformOneOfAnyOf: true, + }); + + expect(zodSchema).toBeDefined(); + + // Test with additional properties + const testData1 = { + randomField: 'value', + anotherField: 123, + }; + + expect(zodSchema?.parse(testData1)).toEqual(testData1); + + // Test with specific field + const testData2 = { + specificField: 'test', + }; + + expect(zodSchema?.parse(testData2)).toEqual(testData2); + }); + + it('should handle complex nested schema with $ref-like structure', () => { + const schema: JsonSchemaType & { anyOf?: any } = { + type: 'object', + properties: { + query: { + type: 'object', + properties: { + filter: { + description: 'Filter conditions', + anyOf: [ + { + // This simulates a resolved $ref + anyOf: [ + { + type: 'object', + properties: { + _or: { + type: 'array', + items: { type: 'object' }, + }, + }, + required: ['_or'], + }, + { + type: 'object', + additionalProperties: { + anyOf: [ + { + type: 'object', + properties: { + _icontains: { type: 'string' }, + _eq: { type: 'string' }, + }, + }, + ], + }, + }, + ], + }, + { + type: 'null', + }, + ], + } as JsonSchemaType & { anyOf?: any }, + }, + }, + }, + }; + + const zodSchema = convertWithResolvedRefs(schema, { + allowEmptyObject: false, + transformOneOfAnyOf: true, + }); + + expect(zodSchema).toBeDefined(); + + const testData = { + query: { + filter: { + title: { + _icontains: 'Pirate', + }, + }, + }, + }; + + const result = zodSchema?.parse(testData); + expect(result).toEqual(testData); + expect(result?.query?.filter?.title?._icontains).toBe('Pirate'); + }); + }); + + describe('$ref resolution with resolveJsonSchemaRefs', () => { + it('should handle schemas with $ref references when resolved', () => { + const schemaWithRefs = { + type: 'object' as const, + properties: { + collection: { + type: 'string' as const, + }, + query: { + type: 'object' as const, + properties: { + filter: { + anyOf: [{ $ref: '#/$defs/__schema0' }, { type: 'null' as const }], + }, + }, + }, + }, + required: ['collection', 'query'], + $defs: { + __schema0: { + anyOf: [ + { + type: 'object' as const, + properties: { + _or: { + type: 'array' as const, + items: { $ref: '#/$defs/__schema0' }, + }, + }, + required: ['_or'], + }, + { + type: 'object' as const, + additionalProperties: { + anyOf: [ + { + type: 'object' as const, + properties: { + _eq: { + anyOf: [ + { type: 'string' as const }, + { type: 'number' as const }, + { type: 'null' as const }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + // First test without resolving refs - should not work properly + // Intentionally NOT using convertWithResolvedRefs here to test the behavior without ref resolution + const zodSchemaUnresolved = convertJsonSchemaToZod(schemaWithRefs as any, { + allowEmptyObject: true, + transformOneOfAnyOf: true, + }); + + const testData = { + collection: 'posts', + query: { + filter: { + status: { + _eq: 'draft', + }, + }, + }, + }; + + // Without resolving refs, the filter field won't work correctly + const resultUnresolved = zodSchemaUnresolved?.parse(testData); + expect(resultUnresolved?.query?.filter).toEqual({}); + + // Now resolve refs first + const resolvedSchema = resolveJsonSchemaRefs(schemaWithRefs); + + // Verify refs were resolved + expect(resolvedSchema.properties?.query?.properties?.filter?.anyOf?.[0]).not.toHaveProperty( + '$ref', + ); + expect(resolvedSchema.properties?.query?.properties?.filter?.anyOf?.[0]).toHaveProperty( + 'anyOf', + ); + + // Already resolved manually above, so we use convertJsonSchemaToZod directly + const zodSchemaResolved = convertJsonSchemaToZod(resolvedSchema as any, { + allowEmptyObject: true, + transformOneOfAnyOf: true, + }); + + // With resolved refs, it should work correctly + const resultResolved = zodSchemaResolved?.parse(testData); + expect(resultResolved).toEqual(testData); + expect(resultResolved?.query?.filter?.status?._eq).toBe('draft'); + }); + + it('should handle circular $ref references without infinite loops', () => { + const schemaWithCircularRefs = { + type: 'object' as const, + properties: { + node: { $ref: '#/$defs/TreeNode' }, + }, + $defs: { + TreeNode: { + type: 'object' as const, + properties: { + value: { type: 'string' as const }, + children: { + type: 'array' as const, + items: { $ref: '#/$defs/TreeNode' }, + }, + }, + }, + }, + }; + + // Should not throw or hang + const resolved = resolveJsonSchemaRefs(schemaWithCircularRefs); + expect(resolved).toBeDefined(); + + // The circular reference should be broken with a simple object schema + // Already resolved manually above, so we use convertJsonSchemaToZod directly + const zodSchema = convertJsonSchemaToZod(resolved as any, { + allowEmptyObject: true, + transformOneOfAnyOf: true, + }); + + expect(zodSchema).toBeDefined(); + + const testData = { + node: { + value: 'root', + children: [ + { + value: 'child1', + children: [], + }, + ], + }, + }; + + expect(() => zodSchema?.parse(testData)).not.toThrow(); + }); + + it('should handle various edge cases safely', () => { + // Test with null/undefined + expect(resolveJsonSchemaRefs(null as any)).toBeNull(); + expect(resolveJsonSchemaRefs(undefined as any)).toBeUndefined(); + + // Test with non-object primitives + expect(resolveJsonSchemaRefs('string' as any)).toBe('string'); + expect(resolveJsonSchemaRefs(42 as any)).toBe(42); + expect(resolveJsonSchemaRefs(true as any)).toBe(true); + + // Test with arrays + const arrayInput = [{ type: 'string' }, { $ref: '#/def' }]; + const arrayResult = resolveJsonSchemaRefs(arrayInput as any); + expect(Array.isArray(arrayResult)).toBe(true); + expect(arrayResult).toHaveLength(2); + + // Test with schema that has no refs + const noRefSchema = { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + nested: { + type: 'object' as const, + properties: { + value: { type: 'number' as const }, + }, + }, + }, + }; + + const resolvedNoRef = resolveJsonSchemaRefs(noRefSchema); + expect(resolvedNoRef).toEqual(noRefSchema); + + // Test with invalid ref (non-existent) + const invalidRefSchema = { + type: 'object' as const, + properties: { + item: { $ref: '#/$defs/nonExistent' }, + }, + $defs: { + other: { type: 'string' as const }, + }, + }; + + const resolvedInvalid = resolveJsonSchemaRefs(invalidRefSchema); + // Invalid refs should be preserved as-is + expect(resolvedInvalid.properties?.item?.$ref).toBe('#/$defs/nonExistent'); + + // Test with empty object + expect(resolveJsonSchemaRefs({})).toEqual({}); + + // Test with schema containing special JSON Schema keywords + const schemaWithKeywords = { + type: 'object' as const, + properties: { + value: { + type: 'string' as const, + minLength: 5, + maxLength: 10, + pattern: '^[A-Z]', + }, + }, + additionalProperties: false, + minProperties: 1, + }; + + const resolvedKeywords = resolveJsonSchemaRefs(schemaWithKeywords); + expect(resolvedKeywords).toEqual(schemaWithKeywords); + expect(resolvedKeywords.properties?.value?.minLength).toBe(5); + expect(resolvedKeywords.additionalProperties).toBe(false); + }); + }); }); diff --git a/packages/data-provider/src/zod.ts b/packages/api/src/mcp/zod.ts similarity index 80% rename from packages/data-provider/src/zod.ts rename to packages/api/src/mcp/zod.ts index bffc693b4a4..bd0a6e1f1b9 100644 --- a/packages/data-provider/src/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -1,30 +1,16 @@ import { z } from 'zod'; - -export type JsonSchemaType = { - type: 'string' | 'number' | 'boolean' | 'array' | 'object'; - enum?: string[]; - items?: JsonSchemaType; - properties?: Record; - required?: string[]; - description?: string; - additionalProperties?: boolean | JsonSchemaType; -}; +import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types'; function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean { return ( jsonSchema != null && typeof jsonSchema === 'object' && jsonSchema.type === 'object' && - (jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) + (jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) && + !jsonSchema.additionalProperties // Don't treat objects with additionalProperties as empty ); } -type ConvertJsonSchemaToZodOptions = { - allowEmptyObject?: boolean; - dropFields?: string[]; - transformOneOfAnyOf?: boolean; -}; - function dropSchemaFields( schema: JsonSchemaType | undefined, fields: string[], @@ -98,6 +84,10 @@ function convertToZodUnion( return convertJsonSchemaToZod(objSchema, options); } + return convertJsonSchemaToZod(objSchema, options); + } else if (!subSchema.type && subSchema.additionalProperties) { + // It's likely an object schema with additionalProperties + const objSchema = { ...subSchema, type: 'object' } as JsonSchemaType; return convertJsonSchemaToZod(objSchema, options); } else if (!subSchema.type && subSchema.items) { // It's likely an array schema @@ -182,6 +172,82 @@ function convertToZodUnion( return zodSchemas[0]; } +/** + * Helper function to resolve $ref references + * @param schema - The schema to resolve + * @param definitions - The definitions to use + * @param visited - The set of visited references + * @returns The resolved schema + */ +export function resolveJsonSchemaRefs>( + schema: T, + definitions?: Record, + visited = new Set(), +): T { + // Handle null, undefined, or non-object values first + if (!schema || typeof schema !== 'object') { + return schema; + } + + // If no definitions provided, try to extract from schema.$defs or schema.definitions + if (!definitions) { + definitions = (schema.$defs || schema.definitions) as Record; + } + + // Handle arrays + if (Array.isArray(schema)) { + return schema.map((item) => resolveJsonSchemaRefs(item, definitions, visited)) as unknown as T; + } + + // Handle objects + const result: Record = {}; + + for (const [key, value] of Object.entries(schema)) { + // Skip $defs/definitions at root level to avoid infinite recursion + if ((key === '$defs' || key === 'definitions') && !visited.size) { + result[key] = value; + continue; + } + + // Handle $ref + if (key === '$ref' && typeof value === 'string') { + // Prevent circular references + if (visited.has(value)) { + // Return a simple schema to break the cycle + return { type: 'object' } as unknown as T; + } + + // Extract the reference path + const refPath = value.replace(/^#\/(\$defs|definitions)\//, ''); + const resolved = definitions?.[refPath]; + + if (resolved) { + visited.add(value); + const resolvedSchema = resolveJsonSchemaRefs( + resolved as Record, + definitions, + visited, + ); + visited.delete(value); + + // Merge the resolved schema into the result + Object.assign(result, resolvedSchema); + } else { + // If we can't resolve the reference, keep it as is + result[key] = value; + } + } else if (value && typeof value === 'object') { + // Recursively resolve nested objects/arrays + result[key] = resolveJsonSchemaRefs(value as Record, definitions, visited); + } else { + // Copy primitive values as is + result[key] = value; + } + } + + return result as T; +} + export function convertJsonSchemaToZod( schema: JsonSchemaType & Record, options: ConvertJsonSchemaToZodOptions = {}, @@ -393,3 +459,15 @@ export function convertJsonSchemaToZod( return zodSchema; } + +/** + * Helper function for tests that automatically resolves refs before converting to Zod + * This ensures all tests use resolveJsonSchemaRefs even when not explicitly testing it + */ +export function convertWithResolvedRefs( + schema: JsonSchemaType & Record, + options?: ConvertJsonSchemaToZodOptions, +) { + const resolved = resolveJsonSchemaRefs(schema); + return convertJsonSchemaToZod(resolved, options); +} diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 6db727529a8..75ee614ff1c 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -4,3 +4,4 @@ export * from './google'; export * from './mistral'; export * from './openai'; export * from './run'; +export * from './zod'; diff --git a/packages/api/src/types/zod.ts b/packages/api/src/types/zod.ts new file mode 100644 index 00000000000..e8b3c9327d7 --- /dev/null +++ b/packages/api/src/types/zod.ts @@ -0,0 +1,15 @@ +export type JsonSchemaType = { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + enum?: string[]; + items?: JsonSchemaType; + properties?: Record; + required?: string[]; + description?: string; + additionalProperties?: boolean | JsonSchemaType; +}; + +export type ConvertJsonSchemaToZodOptions = { + allowEmptyObject?: boolean; + dropFields?: string[]; + transformOneOfAnyOf?: boolean; +}; diff --git a/packages/api/src/utils/key.test.ts b/packages/api/src/utils/key.test.ts new file mode 100644 index 00000000000..29f34addeed --- /dev/null +++ b/packages/api/src/utils/key.test.ts @@ -0,0 +1,182 @@ +import fs from 'fs'; +import path from 'path'; +import axios from 'axios'; +import { loadServiceKey } from './key'; + +jest.mock('fs'); +jest.mock('axios'); +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + }, +})); + +describe('loadServiceKey', () => { + const mockServiceKey = { + type: 'service_account', + project_id: 'test-project', + private_key_id: 'test-key-id', + private_key: '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + client_email: 'test@test-project.iam.gserviceaccount.com', + client_id: '123456789', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://oauth2.googleapis.com/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: + 'https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null if keyPath is empty', async () => { + const result = await loadServiceKey(''); + expect(result).toBeNull(); + }); + + it('should parse stringified JSON directly', async () => { + const jsonString = JSON.stringify(mockServiceKey); + const result = await loadServiceKey(jsonString); + expect(result).toEqual(mockServiceKey); + }); + + it('should parse stringified JSON with leading/trailing whitespace', async () => { + const jsonString = ` ${JSON.stringify(mockServiceKey)} `; + const result = await loadServiceKey(jsonString); + expect(result).toEqual(mockServiceKey); + }); + + it('should load from file path', async () => { + const filePath = '/path/to/service-key.json'; + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockServiceKey)); + + const result = await loadServiceKey(filePath); + expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(filePath), 'utf8'); + expect(result).toEqual(mockServiceKey); + }); + + it('should load from URL', async () => { + const url = 'https://example.com/service-key.json'; + (axios.get as jest.Mock).mockResolvedValue({ data: mockServiceKey }); + + const result = await loadServiceKey(url); + expect(axios.get).toHaveBeenCalledWith(url); + expect(result).toEqual(mockServiceKey); + }); + + it('should handle invalid JSON string', async () => { + const invalidJson = '{ invalid json }'; + const result = await loadServiceKey(invalidJson); + expect(result).toBeNull(); + }); + + it('should handle file read errors', async () => { + const filePath = '/path/to/nonexistent.json'; + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw new Error('File not found'); + }); + + const result = await loadServiceKey(filePath); + expect(result).toBeNull(); + }); + + it('should handle URL fetch errors', async () => { + const url = 'https://example.com/service-key.json'; + (axios.get as jest.Mock).mockRejectedValue(new Error('Network error')); + + const result = await loadServiceKey(url); + expect(result).toBeNull(); + }); + + it('should validate service key format', async () => { + const invalidServiceKey = { invalid: 'key' }; + const result = await loadServiceKey(JSON.stringify(invalidServiceKey)); + expect(result).toEqual(invalidServiceKey); // It returns the object as-is, validation is minimal + }); + + it('should handle escaped newlines in private key from AWS Secrets Manager', async () => { + const serviceKeyWithEscapedNewlines = { + ...mockServiceKey, + private_key: '-----BEGIN PRIVATE KEY-----\\ntest-key\\n-----END PRIVATE KEY-----', + }; + const jsonString = JSON.stringify(serviceKeyWithEscapedNewlines); + + const result = await loadServiceKey(jsonString); + expect(result).not.toBeNull(); + expect(result?.private_key).toBe( + '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + ); + }); + + it('should handle double-escaped newlines in private key', async () => { + // When you have \\n in JavaScript, JSON.stringify converts it to \\\\n + // But we want to test the case where the JSON string contains \\n (single backslash + n) + const serviceKeyWithEscapedNewlines = { + ...mockServiceKey, + private_key: '-----BEGIN PRIVATE KEY-----\\ntest-key\\n-----END PRIVATE KEY-----', + }; + // This will create a JSON string where the private_key contains literal \n (backslash-n) + const jsonString = JSON.stringify(serviceKeyWithEscapedNewlines); + + const result = await loadServiceKey(jsonString); + expect(result).not.toBeNull(); + expect(result?.private_key).toBe( + '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + ); + }); + + it('should handle private key without any newlines', async () => { + const serviceKeyWithoutNewlines = { + ...mockServiceKey, + private_key: '-----BEGIN PRIVATE KEY-----test-key-----END PRIVATE KEY-----', + }; + const jsonString = JSON.stringify(serviceKeyWithoutNewlines); + + const result = await loadServiceKey(jsonString); + expect(result).not.toBeNull(); + expect(result?.private_key).toBe( + '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + ); + }); + + it('should not modify private key that already has proper formatting', async () => { + const jsonString = JSON.stringify(mockServiceKey); + + const result = await loadServiceKey(jsonString); + expect(result).not.toBeNull(); + expect(result?.private_key).toBe(mockServiceKey.private_key); + }); + + it('should handle base64 encoded service key', async () => { + const jsonString = JSON.stringify(mockServiceKey); + const base64Encoded = Buffer.from(jsonString).toString('base64'); + + const result = await loadServiceKey(base64Encoded); + expect(result).not.toBeNull(); + expect(result).toEqual(mockServiceKey); + }); + + it('should handle base64 encoded service key with escaped newlines', async () => { + const serviceKeyWithEscapedNewlines = { + ...mockServiceKey, + private_key: '-----BEGIN PRIVATE KEY-----\\ntest-key\\n-----END PRIVATE KEY-----', + }; + const jsonString = JSON.stringify(serviceKeyWithEscapedNewlines); + const base64Encoded = Buffer.from(jsonString).toString('base64'); + + const result = await loadServiceKey(base64Encoded); + expect(result).not.toBeNull(); + expect(result?.private_key).toBe( + '-----BEGIN PRIVATE KEY-----\ntest-key\n-----END PRIVATE KEY-----', + ); + }); + + it('should handle invalid base64 strings gracefully', async () => { + // This looks like base64 but isn't valid + const invalidBase64 = 'SGVsbG8gV29ybGQ='; // "Hello World" in base64, not valid JSON + + const result = await loadServiceKey(invalidBase64); + expect(result).toBeNull(); + }); +}); diff --git a/packages/api/src/utils/key.ts b/packages/api/src/utils/key.ts index 7d603afd3f7..086e74c06ed 100644 --- a/packages/api/src/utils/key.ts +++ b/packages/api/src/utils/key.ts @@ -18,8 +18,8 @@ export interface GoogleServiceKey { } /** - * Load Google service key from file path or URL - * @param keyPath - The path or URL to the service key file + * Load Google service key from file path, URL, or stringified JSON + * @param keyPath - The path to the service key file, URL to fetch it from, or stringified JSON * @returns The parsed service key object or null if failed */ export async function loadServiceKey(keyPath: string): Promise { @@ -29,8 +29,29 @@ export async function loadServiceKey(keyPath: string): Promise `${agents({ path: `${agent_id}/revert` })}`; export const files = () => '/api/files'; +export const fileUpload = () => '/api/files'; +export const fileDelete = () => '/api/files'; +export const fileDownload = (userId: string, fileId: string) => + `/api/files/download/${userId}/${fileId}`; +export const fileConfig = () => '/api/files/config'; +export const agentFiles = (agentId: string) => `/api/files/agent/${agentId}`; export const images = () => `${files()}/images`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2e042e2681e..a6ac8b3ecf5 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1064,10 +1064,12 @@ export enum InfiniteCollections { * Enum for time intervals */ export enum Time { + ONE_DAY = 86400000, ONE_HOUR = 3600000, THIRTY_MINUTES = 1800000, TEN_MINUTES = 600000, FIVE_MINUTES = 300000, + THREE_MINUTES = 180000, TWO_MINUTES = 120000, ONE_MINUTE = 60000, THIRTY_SECONDS = 30000, @@ -1167,6 +1169,14 @@ export enum CacheKeys { * key for open id exchanged tokens */ OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS', + /** + * Key for OpenID session. + */ + OPENID_SESSION = 'openid_session', + /** + * Key for SAML session. + */ + SAML_SESSION = 'saml_session', } /** @@ -1213,6 +1223,30 @@ export enum ViolationTypes { * Tool Call Limit Violation. */ TOOL_CALL_LIMIT = 'tool_call_limit', + /** + * General violation (catch-all). + */ + GENERAL = 'general', + /** + * Login attempt violations. + */ + LOGINS = 'logins', + /** + * Concurrent request violations. + */ + CONCURRENT = 'concurrent', + /** + * Non-browser access violations. + */ + NON_BROWSER = 'non_browser', + /** + * Message limit violations. + */ + MESSAGE_LIMIT = 'message_limit', + /** + * Registration violations. + */ + REGISTRATIONS = 'registrations', } /** diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index c21a115facd..a0eacb244db 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -4,14 +4,15 @@ import * as s from './schemas'; export default function createPayload(submission: t.TSubmission) { const { - conversation, - userMessage, - endpointOption, isEdited, + userMessage, isContinued, isTemporary, - ephemeralAgent, + isRegenerate, + conversation, editedContent, + ephemeralAgent, + endpointOption, } = submission; const { conversationId } = s.tConvoUpdateSchema.parse(conversation); const { endpoint: _e, endpointType } = endpointOption as { @@ -31,11 +32,12 @@ export default function createPayload(submission: t.TSubmission) { ...userMessage, ...endpointOption, endpoint, - ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, - isContinued: !!(isEdited && isContinued), - conversationId, isTemporary, + isRegenerate, editedContent, + conversationId, + isContinued: !!(isEdited && isContinued), + ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, }; return { server, payload }; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c76efbac876..dde248e2ad8 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -318,6 +318,10 @@ export const getFiles = (): Promise => { return request.get(endpoints.files()); }; +export const getAgentFiles = (agentId: string): Promise => { + return request.get(endpoints.agentFiles(agentId)); +}; + export const getFileConfig = (): Promise => { return request.get(`${endpoints.files()}/config`); }; diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 9869029adf4..d80861acfe5 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -8,7 +8,6 @@ export * from './artifacts'; /* schema helpers */ export * from './parsers'; export * from './ocr'; -export * from './zod'; /* custom/dynamic configurations */ export * from './generate'; export * from './models'; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 93e21c7e453..ec94c0f0ff0 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -50,6 +50,11 @@ export enum QueryKeys { memories = 'memories', } +// Dynamic query keys that require parameters +export const DynamicQueryKeys = { + agentFiles: (agentId: string) => ['agentFiles', agentId] as const, +} as const; + export enum MutationKeys { fileUpload = 'fileUpload', fileDelete = 'fileDelete', diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index f01ad6139cd..dee4e8b77fe 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -284,6 +284,19 @@ const openAIParams: Record = { optionType: 'model', columnSpan: 4, }, + disableStreaming: { + key: 'disableStreaming', + label: 'com_endpoint_disable_streaming_label', + labelCode: true, + description: 'com_endpoint_disable_streaming', + descriptionCode: true, + type: 'boolean', + default: false, + component: 'switch', + optionType: 'model', + showDefault: false, + columnSpan: 2, + } as const, }; const anthropic: Record = { @@ -626,6 +639,7 @@ const openAI: SettingsConfiguration = [ openAIParams.reasoning_effort, openAIParams.useResponsesApi, openAIParams.reasoning_summary, + openAIParams.disableStreaming, ]; const openAICol1: SettingsConfiguration = [ @@ -648,6 +662,7 @@ const openAICol2: SettingsConfiguration = [ openAIParams.reasoning_summary, openAIParams.useResponsesApi, openAIParams.web_search, + openAIParams.disableStreaming, ]; const anthropicConfig: SettingsConfiguration = [ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 3ea250f4a44..bc488d40403 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -534,7 +534,7 @@ export type MemoryArtifact = { key: string; value?: string; tokenCount?: number; - type: 'update' | 'delete'; + type: 'update' | 'delete' | 'error'; }; export type TAttachmentMetadata = { @@ -639,6 +639,8 @@ export const tConversationSchema = z.object({ useResponsesApi: z.boolean().optional(), /* OpenAI Responses API / Anthropic API / Google API */ web_search: z.boolean().optional(), + /* disable streaming */ + disableStreaming: z.boolean().optional(), /* assistant */ assistant_id: z.string().optional(), /* agents */ @@ -743,6 +745,8 @@ export const tQueryParamsSchema = tConversationSchema useResponsesApi: true, /** @endpoints openAI, anthropic, google */ web_search: true, + /** @endpoints openAI, custom, azureOpenAI */ + disableStreaming: true, /** @endpoints google, anthropic, bedrock */ topP: true, /** @endpoints google, anthropic */ @@ -1075,6 +1079,7 @@ export const openAIBaseSchema = tConversationSchema.pick({ reasoning_summary: true, useResponsesApi: true, web_search: true, + disableStreaming: true, }); export const openAISchema = openAIBaseSchema diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 3c8ccc870fa..b1fbc426532 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -105,6 +105,7 @@ export type TEphemeralAgent = { export type TPayload = Partial & Partial & { isContinued: boolean; + isRegenerate?: boolean; conversationId: string | null; messages?: TMessages; isTemporary: boolean; @@ -125,7 +126,6 @@ export type TSubmission = { isTemporary: boolean; messages: TMessage[]; isRegenerate?: boolean; - isResubmission?: boolean; initialResponse?: TMessage; conversation: Partial; endpointOption: TEndpointOption; diff --git a/packages/data-schemas/src/schema/defaults.ts b/packages/data-schemas/src/schema/defaults.ts index c735ecd9922..da986d729f4 100644 --- a/packages/data-schemas/src/schema/defaults.ts +++ b/packages/data-schemas/src/schema/defaults.ts @@ -134,12 +134,11 @@ export const conversationPreset = { useResponsesApi: { type: Boolean, }, - /** OpenAI Responses API / Anthropic API */ + /** OpenAI Responses API / Anthropic API / Google API */ web_search: { type: Boolean, }, - /** Google */ - grounding: { + disableStreaming: { type: Boolean, }, /** Reasoning models only */ diff --git a/packages/data-schemas/src/schema/preset.ts b/packages/data-schemas/src/schema/preset.ts index 1b128413f3a..c6ee8e61225 100644 --- a/packages/data-schemas/src/schema/preset.ts +++ b/packages/data-schemas/src/schema/preset.ts @@ -48,6 +48,8 @@ export interface IPreset extends Document { reasoning_effort?: string; reasoning_summary?: string; useResponsesApi?: boolean; + web_search?: boolean; + disableStreaming?: boolean; // end of additional fields agentOptions?: unknown; } diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index f7e58508f0e..cf2e1d2efc3 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -48,6 +48,7 @@ export interface IConversation extends Document { reasoning_summary?: string; useResponsesApi?: boolean; web_search?: boolean; + disableStreaming?: boolean; // Additional fields files?: string[]; expiredAt?: Date; diff --git a/redis-config/README.md b/redis-config/README.md new file mode 100644 index 00000000000..024d0b1683c --- /dev/null +++ b/redis-config/README.md @@ -0,0 +1,399 @@ +# Redis Configuration and Setup + +This directory contains comprehensive Redis configuration files and scripts for LibreChat development and testing, supporting both cluster and single-node setups with optional TLS encryption. + +## Supported Configurations + +### 1. Redis Cluster (3 Nodes) +- **3 Redis nodes** running on ports 7001, 7002, and 7003 +- **No replicas** (each node is a master) +- **Automatic hash slot distribution** across all nodes + +### 2. Single Redis with TLS Encryption +- **Single Redis instance** on port 6380 with TLS encryption +- **CA certificate validation** for secure connections +- **Self-signed certificates** with proper Subject Alternative Names + +### 3. Standard Single Redis +- **Basic Redis instance** on port 6379 (default) +- **No encryption** - suitable for local development + +All configurations are designed for **local development and testing**. + +## Prerequisites + +1. **Redis** must be installed on your system: + ```bash + # macOS + brew install redis + + # Ubuntu/Debian + sudo apt-get install redis-server + + # CentOS/RHEL + sudo yum install redis + ``` + +2. **Redis CLI** should be available (usually included with Redis) + +## Quick Start + +### Option 1: Redis Cluster (3 Nodes) + +```bash +# Navigate to the redis-config directory +cd redis-config + +# Start and initialize the cluster +./start-cluster.sh +``` + +### Option 2: Single Redis with TLS + +```bash +# Start Redis with TLS encryption on port 6380 +./start-redis-tls.sh +``` + +### Option 3: Standard Redis + +```bash +# Use system Redis on default port 6379 +redis-server +``` + +## Testing Your Setup + +### Test Cluster +```bash +# Connect to the cluster +redis-cli -c -p 7001 + +# Test basic operations +SET test_key "Hello World" +GET test_key +``` + +### Test TLS Redis +```bash +# Test with CA certificate validation +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 ping +``` + +### Test Standard Redis +```bash +# Connect to default Redis +redis-cli ping +``` + +## Stopping Services + +### Stop Cluster +```bash +./stop-cluster.sh +``` + +### Stop TLS Redis +```bash +# Find and stop TLS Redis process +ps aux | grep "redis-server.*6380" +kill +``` + +## Configuration Files + +- `redis-7001.conf` - Configuration for node 1 (port 7001) +- `redis-7002.conf` - Configuration for node 2 (port 7002) +- `redis-7003.conf` - Configuration for node 3 (port 7003) + +## Scripts + +- `start-cluster.sh` - Starts and initializes the Redis cluster +- `stop-cluster.sh` - Stops all Redis nodes and cleans up +- `start-redis-tls.sh` - Starts Redis with TLS encryption and CA certificate validation +- `redis-tls.conf` - TLS Redis configuration file + +## Directory Structure + +``` +redis-config/ +├── README.md +├── redis-7001.conf # Cluster node 1 configuration +├── redis-7002.conf # Cluster node 2 configuration +├── redis-7003.conf # Cluster node 3 configuration +├── redis-tls.conf # TLS Redis configuration +├── start-cluster.sh # Start cluster script +├── stop-cluster.sh # Stop cluster script +├── start-redis-tls.sh # Start TLS Redis script +├── certs/ # TLS certificates (created automatically) +│ ├── ca-cert.pem # Certificate Authority certificate +│ ├── ca-key.pem # CA private key +│ ├── server-cert.pem # Server certificate with SAN +│ ├── server-key.pem # Server private key +│ ├── redis.dh # Diffie-Hellman parameters +│ └── server.conf # OpenSSL certificate configuration +├── data/ # Data files (created automatically) +│ ├── 7001/ # Cluster node 1 data +│ ├── 7002/ # Cluster node 2 data +│ └── 7003/ # Cluster node 3 data +└── logs/ # Log directory (created automatically) + # Note: By default, Redis logs to stdout/stderr + # Log files would be created here if enabled in config +``` + +## Using with LibreChat + +Update your `.env` file based on your chosen Redis configuration: + +### For Redis Cluster +```bash +USE_REDIS=true +REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 +``` + +### For TLS Redis +```bash +USE_REDIS=true +REDIS_URI=rediss://127.0.0.1:6380 +REDIS_CA=/path/to/LibreChat/redis-config/certs/ca-cert.pem +``` + +### For Standard Redis +```bash +USE_REDIS=true +REDIS_URI=redis://127.0.0.1:6379 +``` + +### Optional Configuration +```bash +# Use environment variable for dynamic key prefixing +REDIS_KEY_PREFIX_VAR=K_REVISION + +# Or set static prefix +REDIS_KEY_PREFIX=librechat + +# Connection limits +REDIS_MAX_LISTENERS=40 +``` + +## TLS/SSL Redis Setup + +For secure Redis connections using TLS encryption with CA certificate validation: + +### 1. Start Redis with TLS + +```bash +# Start Redis with TLS on port 6380 +./start-redis-tls.sh +``` + +### 2. Configure LibreChat for TLS + +Update your `.env` file: + +```bash +# .env file - TLS Redis with CA certificate validation +USE_REDIS=true +REDIS_URI=rediss://127.0.0.1:6380 +REDIS_CA=/path/to/LibreChat/redis-config/certs/ca-cert.pem +``` + +### 3. Test TLS Connection + +```bash +# Test Redis TLS connection with CA certificate +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 ping + +# Should return: PONG + +# Test basic operations +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 set test_tls "TLS Working" +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 get test_tls +``` + +### 4. Test Backend Integration + +```bash +# Start LibreChat backend +npm run backend + +# Look for these success indicators in logs: +# ✅ "No changes needed for 'USER' role permissions" +# ✅ "No changes needed for 'ADMIN' role permissions" +# ✅ "Server listening at http://localhost:3080" +# ✅ No "IoRedis connection error" messages +``` + +### TLS Certificate Details + +The TLS setup includes: + +- **CA Certificate**: Self-signed Certificate Authority for validation +- **Server Certificate**: Contains Subject Alternative Names (SAN) for: + - `DNS: localhost` + - `IP: 127.0.0.1` +- **TLS Configuration**: + - TLS v1.2 and v1.3 support + - No client certificate authentication required + - Strong cipher suites (AES-256-GCM, ChaCha20-Poly1305) + +### Troubleshooting TLS + +#### Certificate Validation Errors + +```bash +# If you see "Hostname/IP does not match certificate's altnames" +# Check certificate SAN entries: +openssl x509 -in certs/server-cert.pem -text -noout | grep -A3 "Subject Alternative Name" + +# Should show: DNS:localhost, IP Address:127.0.0.1 +``` + +#### Connection Refused + +```bash +# Check if Redis TLS is running +lsof -i :6380 + +# Check Redis TLS server logs +ps aux | grep redis-server +``` + +#### Backend Connection Issues + +```bash +# Verify CA certificate path in .env +ls -la /path/to/LibreChat/redis-config/certs/ca-cert.pem + +# Test LibreChat Redis configuration +cd /path/to/LibreChat +npm run backend +# Look for Redis connection errors in output +``` + +## Common Operations + +### Check Cluster Status + +```bash +# Cluster information +redis-cli -p 7001 cluster info + +# Node information +redis-cli -p 7001 cluster nodes + +# Check specific node +redis-cli -p 7002 info replication +``` + +### Monitor Cluster + +```bash +# Monitor all operations +redis-cli -p 7001 monitor + +# Check memory usage +redis-cli -p 7001 info memory +redis-cli -p 7002 info memory +redis-cli -p 7003 info memory +``` + +### Troubleshooting + +#### Cluster Won't Start + +1. Check if Redis is installed: `redis-server --version` +2. Check for port conflicts: `netstat -tlnp | grep :700` +3. Check Redis processes: `ps aux | grep redis-server` +4. Check if nodes are responding: `redis-cli -p 7001 ping` + +#### Cluster Initialization Fails + +1. Ensure all nodes are running: `./start-cluster.sh` +2. Check cluster configuration: `redis-cli -p 7001 cluster nodes` +3. Reset if needed: `redis-cli -p 7001 CLUSTER RESET` + +#### Performance Issues + +1. Monitor memory usage: `redis-cli -p 7001 info memory` +2. Check slow queries: `redis-cli -p 7001 slowlog get 10` +3. Adjust `maxmemory` settings in configuration files + +## Configuration Details + +### Node Configuration + +Each node is configured with: +- **Memory limit**: 256MB with LRU eviction +- **Persistence**: AOF + RDB snapshots +- **Clustering**: Enabled with 15-second timeout +- **Logging**: Notice level (logs to stdout/stderr by default) + +### Hash Slot Distribution + +With 3 nodes and no replicas: +- Node 1 (7001): Hash slots 0-5460 +- Node 2 (7002): Hash slots 5461-10922 +- Node 3 (7003): Hash slots 10923-16383 + +## Security Note + +### Development Setup +The basic Redis cluster setup is designed for **local development only**. + +### TLS Setup +The TLS Redis configuration provides: +- ✅ **TLS encryption** with CA certificate validation +- ✅ **Server certificate** with proper Subject Alternative Names +- ✅ **Strong cipher suites** (AES-256-GCM, ChaCha20-Poly1305) +- ✅ **Certificate validation** via self-signed CA + +### Production Considerations +For production use, consider: +- Authentication (`requirepass` or `AUTH` commands) +- Client certificate authentication (`tls-auth-clients yes`) +- Firewall configuration +- Replica nodes for high availability +- Proper certificate management (not self-signed) +- Key rotation policies + +## Backup and Recovery + +### Backup + +```bash +# Backup all nodes +mkdir -p backup +redis-cli -p 7001 BGSAVE +redis-cli -p 7002 BGSAVE +redis-cli -p 7003 BGSAVE + +# Copy backup files +cp data/7001/dump.rdb backup/dump-7001.rdb +cp data/7002/dump.rdb backup/dump-7002.rdb +cp data/7003/dump.rdb backup/dump-7003.rdb +``` + +### Recovery + +```bash +# Stop cluster +./stop-cluster.sh + +# Restore backup files +cp backup/dump-7001.rdb data/7001/dump.rdb +cp backup/dump-7002.rdb data/7002/dump.rdb +cp backup/dump-7003.rdb data/7003/dump.rdb + +# Start cluster +./start-cluster.sh +``` + +## Support + +For Redis-specific issues: +- [Redis Documentation](https://redis.io/docs/) +- [Redis Cluster Tutorial](https://redis.io/docs/manual/scaling/) + +For LibreChat integration: +- [LibreChat Documentation](https://github.com/danny-avila/LibreChat) \ No newline at end of file diff --git a/redis-config/certs/ca-cert.srl b/redis-config/certs/ca-cert.srl new file mode 100644 index 00000000000..d38dda3b690 --- /dev/null +++ b/redis-config/certs/ca-cert.srl @@ -0,0 +1 @@ +54E48A77C8FF4781A554C80BF6EFC0401A6ACE8A diff --git a/redis-config/certs/dump.rdb b/redis-config/certs/dump.rdb new file mode 100644 index 00000000000..292e946f696 Binary files /dev/null and b/redis-config/certs/dump.rdb differ diff --git a/redis-config/certs/redis.dh b/redis-config/certs/redis.dh new file mode 100644 index 00000000000..c87e70095e3 --- /dev/null +++ b/redis-config/certs/redis.dh @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBDAKCAQEAmPCtZIwB9l9LqyFewdGKxxk3HEcQdHswM3IHbhE+GqZOaD8KwxB+ +rfPH54pDSW42WLWM+y7/eC2ufPdw6ZDBjCYFC8rGkWUPwguDl90INuzCCCAgwBVw +tpKfcZ92T8ek1qR6UgZa4zPq4FjQm09ZcVmMzaUeIkiRGv0/t2GjswZDVuLhKRp5 +eSH7pByYCNYj3X9HyMqcCDfGhTkg8azcWJiEOCTCpsYgXcW1tz2PQsaJZpERnffk +px8mLuDPMVxTRWpXcIBmzs/Nwv8bGigyI4ADocM3jmQ8c6b9ZYUmyvled/1LEBzC +10g2R67op+dGOxk40lwrLmN6bzYFt/YKbwIBAgICAOE= +-----END DH PARAMETERS----- diff --git a/redis-config/certs/server.conf b/redis-config/certs/server.conf new file mode 100644 index 00000000000..ec9a8dc25bc --- /dev/null +++ b/redis-config/certs/server.conf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = localhost + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 \ No newline at end of file diff --git a/redis-config/redis-7001.conf b/redis-config/redis-7001.conf new file mode 100644 index 00000000000..1f0869437ab --- /dev/null +++ b/redis-config/redis-7001.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 1 Configuration +port 7001 +cluster-enabled yes +cluster-config-file nodes-7001.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7001.aof" + +# Data directory +dir ./data/7001 + +# Logging +# logfile ./logs/redis-7001.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-7002.conf b/redis-config/redis-7002.conf new file mode 100644 index 00000000000..e16ac2c6bb7 --- /dev/null +++ b/redis-config/redis-7002.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 2 Configuration +port 7002 +cluster-enabled yes +cluster-config-file nodes-7002.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7002.aof" + +# Data directory +dir ./data/7002 + +# Logging +# logfile ./logs/redis-7002.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-7003.conf b/redis-config/redis-7003.conf new file mode 100644 index 00000000000..216f161d94c --- /dev/null +++ b/redis-config/redis-7003.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 3 Configuration +port 7003 +cluster-enabled yes +cluster-config-file nodes-7003.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7003.aof" + +# Data directory +dir ./data/7003 + +# Logging +# logfile ./logs/redis-7003.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-tls.conf b/redis-config/redis-tls.conf new file mode 100644 index 00000000000..29ebe2e1803 --- /dev/null +++ b/redis-config/redis-tls.conf @@ -0,0 +1,31 @@ +port 0 +tls-port 6380 +tls-cert-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/server-cert.pem +tls-key-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/server-key.pem +tls-ca-cert-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/ca-cert.pem +tls-auth-clients no +tls-dh-params-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/redis.dh +tls-protocols "TLSv1.2 TLSv1.3" +tls-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256 +tls-prefer-server-ciphers yes +tls-session-caching no +tls-session-cache-size 5000 +tls-session-cache-timeout 60 +bind 127.0.0.1 +protected-mode yes +timeout 0 +tcp-keepalive 300 +daemonize no +pidfile /var/run/redis_6379.pid +loglevel notice +logfile "" +databases 16 +always-show-logo no +save 900 1 +save 300 10 +save 60 10000 +stop-writes-on-bgsave-error yes +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb +dir ./ \ No newline at end of file diff --git a/redis-config/start-cluster.sh b/redis-config/start-cluster.sh new file mode 100755 index 00000000000..d46227c3482 --- /dev/null +++ b/redis-config/start-cluster.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# Redis Cluster Startup Script +# This script starts and initializes a 3-node Redis cluster with no replicas + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🚀 Starting Redis Cluster..." + +# Create necessary directories +mkdir -p data/7001 data/7002 data/7003 +mkdir -p logs + +# Check if Redis is installed +if ! command -v redis-server &> /dev/null; then + echo "❌ Redis is not installed. Please install Redis first:" + echo " macOS: brew install redis" + echo " Ubuntu: sudo apt-get install redis-server" + echo " CentOS: sudo yum install redis" + exit 1 +fi + +# Check if Redis CLI is available +if ! command -v redis-cli &> /dev/null; then + echo "❌ Redis CLI is not available. Please install Redis CLI." + exit 1 +fi + +# Start Redis instances +redis-server redis-7001.conf --daemonize yes +redis-server redis-7002.conf --daemonize yes +redis-server redis-7003.conf --daemonize yes + +# Wait for nodes to start +sleep 3 + +# Check if all nodes are running +NODES_RUNNING=0 +for port in 7001 7002 7003; do + if redis-cli -p $port ping &> /dev/null; then + NODES_RUNNING=$((NODES_RUNNING + 1)) + else + echo "❌ Node on port $port failed to start" + fi +done + +if [ $NODES_RUNNING -ne 3 ]; then + echo "❌ Not all Redis nodes started successfully." + exit 1 +fi + +echo "✅ All Redis nodes started" + +# Check if cluster is already initialized +if redis-cli -p 7001 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then + echo "✅ Cluster already initialized" + echo "" + echo "📋 Usage:" + echo " Connect: redis-cli -c -p 7001" + echo " Stop: ./stop-cluster.sh" + exit 0 +fi + +# Initialize the cluster +echo "🔧 Initializing cluster..." +echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 > /dev/null + +# Wait for cluster to stabilize +sleep 3 + +# Verify cluster status +if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then + echo "✅ Redis cluster ready!" + echo "" + echo "📋 Usage:" + echo " Connect: redis-cli -c -p 7001" + echo " Stop: ./stop-cluster.sh" +else + echo "❌ Cluster initialization failed!" + exit 1 +fi \ No newline at end of file diff --git a/redis-config/start-redis-tls.sh b/redis-config/start-redis-tls.sh new file mode 100755 index 00000000000..b9a6a7a4742 --- /dev/null +++ b/redis-config/start-redis-tls.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Start Redis with TLS configuration +echo "Starting Redis with TLS on port 6379..." + +# Check if Redis is already running +if pgrep -f "redis-server.*tls" > /dev/null; then + echo "Redis with TLS is already running" + exit 1 +fi + +# Start Redis with TLS config +redis-server /Users/theotr/WebstormProjects/LibreChat/redis-cluster/redis-tls.conf \ No newline at end of file diff --git a/redis-config/stop-cluster.sh b/redis-config/stop-cluster.sh new file mode 100755 index 00000000000..7f4d47e780c --- /dev/null +++ b/redis-config/stop-cluster.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Redis Cluster Shutdown Script +# This script stops all Redis cluster nodes + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🛑 Stopping Redis Cluster..." + +# Function to stop a Redis node +stop_node() { + local port=$1 + + if redis-cli -p $port ping &> /dev/null; then + # Try graceful shutdown first + redis-cli -p $port SHUTDOWN NOSAVE 2>/dev/null || true + sleep 2 + + # Check if still running and force kill if needed + if redis-cli -p $port ping &> /dev/null; then + PID=$(ps aux | grep "[r]edis-server.*:$port" | awk '{print $2}') + if [ -n "$PID" ]; then + kill -TERM $PID 2>/dev/null || true + sleep 2 + if kill -0 $PID 2>/dev/null; then + kill -KILL $PID 2>/dev/null || true + fi + fi + fi + + # Final check + if redis-cli -p $port ping &> /dev/null; then + echo "❌ Failed to stop Redis node on port $port" + return 1 + else + return 0 + fi + else + return 0 + fi +} + +# Stop all nodes +NODES_STOPPED=0 +for port in 7001 7002 7003; do + if stop_node $port; then + NODES_STOPPED=$((NODES_STOPPED + 1)) + fi +done + +# Clean up cluster configuration files +rm -f nodes-7001.conf nodes-7002.conf nodes-7003.conf + +if [ $NODES_STOPPED -eq 3 ]; then + echo "✅ Redis cluster stopped" +else + echo "⚠️ Some nodes may not have stopped properly" + echo "Check running processes: ps aux | grep redis-server" +fi + +# Check for remaining processes +REMAINING_PROCESSES=$(ps aux | grep "[r]edis-server" | grep -E ":(7001|7002|7003)" | wc -l) +if [ $REMAINING_PROCESSES -gt 0 ]; then + echo "⚠️ Found $REMAINING_PROCESSES remaining Redis processes" + echo "Kill with: pkill -f 'redis-server.*:700[1-3]'" +fi \ No newline at end of file