diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js index 18d370b560..ff1a9de856 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -1,39 +1,41 @@ const axios = require('axios'); const { ErrorTypes } = require('librechat-data-provider'); +const { isEnabled } = require('~/server/utils'); const denyRequest = require('./denyRequest'); const { logger } = require('~/config'); async function moderateText(req, res, next) { - if (process.env.OPENAI_MODERATION === 'true') { - try { - const { text } = req.body; + if (!isEnabled(process.env.OPENAI_MODERATION)) { + return next(); + } + try { + const { text } = req.body; - const response = await axios.post( - process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations', - { - input: text, + const response = await axios.post( + process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations', + { + input: text, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`, }, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`, - }, - }, - ); + }, + ); - const results = response.data.results; - const flagged = results.some((result) => result.flagged); + const results = response.data.results; + const flagged = results.some((result) => result.flagged); - if (flagged) { - const type = ErrorTypes.MODERATION; - const errorMessage = { type }; - return await denyRequest(req, res, errorMessage); - } - } catch (error) { - logger.error('Error in moderateText:', error); - const errorMessage = 'error in moderation check'; + if (flagged) { + const type = ErrorTypes.MODERATION; + const errorMessage = { type }; return await denyRequest(req, res, errorMessage); } + } catch (error) { + logger.error('Error in moderateText:', error); + const errorMessage = 'error in moderation check'; + return await denyRequest(req, res, errorMessage); } next(); } diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index fdb2db54d3..42a18d0100 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -3,6 +3,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { setHeaders, handleAbort, + moderateText, // validateModel, generateCheckAccess, validateConvoAccess, @@ -14,6 +15,7 @@ const addTitle = require('~/server/services/Endpoints/agents/title'); const router = express.Router(); +router.use(moderateText); router.post('/abort', handleAbort()); const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index d7ef93af73..1834d2e2bc 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,21 +1,40 @@ const express = require('express'); -const router = express.Router(); const { uaParser, checkBan, requireJwtAuth, - // concurrentLimiter, - // messageIpLimiter, - // messageUserLimiter, + messageIpLimiter, + concurrentLimiter, + messageUserLimiter, } = require('~/server/middleware'); - +const { isEnabled } = require('~/server/utils'); const { v1 } = require('./v1'); const chat = require('./chat'); +const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; + +const router = express.Router(); + router.use(requireJwtAuth); router.use(checkBan); router.use(uaParser); + router.use('/', v1); -router.use('/chat', chat); + +const chatRouter = express.Router(); +if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { + chatRouter.use(concurrentLimiter); +} + +if (isEnabled(LIMIT_MESSAGE_IP)) { + chatRouter.use(messageIpLimiter); +} + +if (isEnabled(LIMIT_MESSAGE_USER)) { + chatRouter.use(messageUserLimiter); +} + +chatRouter.use('/', chat); +router.use('/chat', chatRouter); module.exports = router; diff --git a/api/server/routes/ask/index.js b/api/server/routes/ask/index.js index bd5666153f..525bd8e29d 100644 --- a/api/server/routes/ask/index.js +++ b/api/server/routes/ask/index.js @@ -1,10 +1,4 @@ const express = require('express'); -const openAI = require('./openAI'); -const custom = require('./custom'); -const google = require('./google'); -const anthropic = require('./anthropic'); -const gptPlugins = require('./gptPlugins'); -const { isEnabled } = require('~/server/utils'); const { EModelEndpoint } = require('librechat-data-provider'); const { uaParser, @@ -15,6 +9,12 @@ const { messageUserLimiter, validateConvoAccess, } = require('~/server/middleware'); +const { isEnabled } = require('~/server/utils'); +const gptPlugins = require('./gptPlugins'); +const anthropic = require('./anthropic'); +const custom = require('./custom'); +const google = require('./google'); +const openAI = require('./openAI'); const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; diff --git a/api/server/routes/bedrock/chat.js b/api/server/routes/bedrock/chat.js index c8d6be35de..11db89f07e 100644 --- a/api/server/routes/bedrock/chat.js +++ b/api/server/routes/bedrock/chat.js @@ -4,6 +4,7 @@ const router = express.Router(); const { setHeaders, handleAbort, + moderateText, // validateModel, // validateEndpoint, buildEndpointOption, @@ -12,6 +13,7 @@ const { initializeClient } = require('~/server/services/Endpoints/bedrock'); const AgentController = require('~/server/controllers/agents/request'); const addTitle = require('~/server/services/Endpoints/agents/title'); +router.use(moderateText); router.post('/abort', handleAbort()); /** diff --git a/api/server/routes/bedrock/index.js b/api/server/routes/bedrock/index.js index b1a9efec4c..ce440a7c0e 100644 --- a/api/server/routes/bedrock/index.js +++ b/api/server/routes/bedrock/index.js @@ -1,19 +1,35 @@ const express = require('express'); -const router = express.Router(); const { uaParser, checkBan, requireJwtAuth, - // concurrentLimiter, - // messageIpLimiter, - // messageUserLimiter, + messageIpLimiter, + concurrentLimiter, + messageUserLimiter, } = require('~/server/middleware'); - +const { isEnabled } = require('~/server/utils'); const chat = require('./chat'); +const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; + +const router = express.Router(); + router.use(requireJwtAuth); router.use(checkBan); router.use(uaParser); + +if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { + router.use(concurrentLimiter); +} + +if (isEnabled(LIMIT_MESSAGE_IP)) { + router.use(messageIpLimiter); +} + +if (isEnabled(LIMIT_MESSAGE_USER)) { + router.use(messageUserLimiter); +} + router.use('/chat', chat); module.exports = router; diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 1d5768d147..e685c8c8c2 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -16,6 +16,7 @@ const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; let s3UrlExpirySeconds = 7 * 24 * 60 * 60; +let s3RefreshExpiryMs = null; if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) { const parsed = parseInt(process.env.S3_URL_EXPIRY_SECONDS, 10); @@ -29,6 +30,19 @@ if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) { } } +if (process.env.S3_REFRESH_EXPIRY_MS !== null && process.env.S3_REFRESH_EXPIRY_MS) { + const parsed = parseInt(process.env.S3_REFRESH_EXPIRY_MS, 10); + + if (!isNaN(parsed) && parsed > 0) { + s3RefreshExpiryMs = parsed; + logger.info(`[S3] Using custom refresh expiry time: ${s3RefreshExpiryMs}ms`); + } else { + logger.warn( + `[S3] Invalid S3_REFRESH_EXPIRY_MS value: "${process.env.S3_REFRESH_EXPIRY_MS}". Using default refresh logic.`, + ); + } +} + /** * Constructs the S3 key based on the base path, user ID, and file name. */ @@ -293,8 +307,16 @@ function needsRefresh(signedUrl, bufferSeconds) { // Check if it's close to expiration const now = new Date(); - const bufferTime = new Date(now.getTime() + bufferSeconds * 1000); + // If S3_REFRESH_EXPIRY_MS is set, use it to determine if URL is expired + if (s3RefreshExpiryMs !== null) { + const urlCreationTime = dateObj.getTime(); + const urlAge = now.getTime() - urlCreationTime; + return urlAge >= s3RefreshExpiryMs; + } + + // Otherwise use the default buffer-based logic + const bufferTime = new Date(now.getTime() + bufferSeconds * 1000); return expiresAtDate <= bufferTime; } catch (error) { logger.error('Error checking URL expiration:', error); diff --git a/client/src/components/Endpoints/URLIcon.tsx b/client/src/components/Endpoints/URLIcon.tsx index ab22635809..7419bde8fc 100644 --- a/client/src/components/Endpoints/URLIcon.tsx +++ b/client/src/components/Endpoints/URLIcon.tsx @@ -55,8 +55,8 @@ export const URLIcon = memo( onError={handleImageError} loading="lazy" decoding="async" - width={Number(containerStyle.width)} - height={Number(containerStyle.height)} + width={Number(containerStyle.width) || 20} + height={Number(containerStyle.height) || 20} /> ); diff --git a/client/src/hooks/Input/useAutoSave.ts b/client/src/hooks/Input/useAutoSave.ts index 6b7be2437d..bebf54af3f 100644 --- a/client/src/hooks/Input/useAutoSave.ts +++ b/client/src/hooks/Input/useAutoSave.ts @@ -11,6 +11,28 @@ const clearDraft = debounce((id?: string | null) => { localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`); }, 2500); +const encodeBase64 = (plainText: string): string => { + try { + const textBytes = new TextEncoder().encode(plainText); + return btoa(String.fromCharCode(...textBytes)); + } catch (e) { + return ''; + } +}; + +const decodeBase64 = (base64String: string): string => { + try { + const bytes = atob(base64String); + const uint8Array = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + uint8Array[i] = bytes.charCodeAt(i); + } + return new TextDecoder().decode(uint8Array); + } catch (e) { + return ''; + } +}; + export const useAutoSave = ({ conversationId, textAreaRef, @@ -30,28 +52,6 @@ export const useAutoSave = ({ const fileIds = useMemo(() => Array.from(files.keys()), [files]); const { data: fileList } = useGetFiles(); - const encodeBase64 = (plainText: string): string => { - try { - const textBytes = new TextEncoder().encode(plainText); - return btoa(String.fromCharCode(...textBytes)); - } catch (e) { - return ''; - } - }; - - const decodeBase64 = (base64String: string): string => { - try { - const bytes = atob(base64String); - const uint8Array = new Uint8Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - uint8Array[i] = bytes.charCodeAt(i); - } - return new TextDecoder().decode(uint8Array); - } catch (e) { - return ''; - } - }; - const restoreFiles = useCallback( (id: string) => { const filesDraft = JSON.parse( @@ -126,16 +126,17 @@ export const useAutoSave = ({ return; } - const handleInput = debounce(() => { - if (textAreaRef?.current && textAreaRef.current.value) { + const handleInput = debounce((e: React.ChangeEvent) => { + const value = e.target.value; + if (value) { localStorage.setItem( `${LocalStorageKeys.TEXT_DRAFT}${conversationId}`, - encodeBase64(textAreaRef.current.value), + encodeBase64(value), ); } else { localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`); } - }, 1000); + }, 750); const textArea = textAreaRef?.current; if (textArea) { diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index e8dd8a1fde..55d1488197 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -645,7 +645,7 @@ export default function useEventHandlers({ } else { cancelHandler(data, submission); } - } else if (response.status === 204) { + } else if (response.status === 204 || response.status === 200) { const responseMessage = { ...submission.initialResponse, }; diff --git a/packages/data-provider/src/zod.spec.ts b/packages/data-provider/src/zod.spec.ts index caa7d54e49..c8a02845b2 100644 --- a/packages/data-provider/src/zod.spec.ts +++ b/packages/data-provider/src/zod.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-conditional-expect */ /* eslint-disable @typescript-eslint/no-explicit-any */ // zod.spec.ts import { z } from 'zod'; @@ -468,6 +467,156 @@ describe('convertJsonSchemaToZod', () => { }); }); + describe('additionalProperties handling', () => { + it('should allow any additional properties when additionalProperties is true', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + additionalProperties: true, + }; + const zodSchema = convertJsonSchemaToZod(schema); + + // Should accept the defined property + expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' }); + + // Should also accept additional properties of any type + expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 }); + expect(zodSchema?.parse({ name: 'John', isActive: true })).toEqual({ + name: 'John', + isActive: true, + }); + expect(zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toEqual({ + name: 'John', + tags: ['tag1', 'tag2'], + }); + }); + + it('should validate additional properties according to schema when additionalProperties is an object', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + additionalProperties: { type: 'number' }, + }; + const zodSchema = convertJsonSchemaToZod(schema); + + // Should accept the defined property + expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' }); + + // Should accept additional properties that match the additionalProperties schema + expect(zodSchema?.parse({ name: 'John', age: 30, score: 100 })).toEqual({ + name: 'John', + age: 30, + score: 100, + }); + + // Should reject additional properties that don't match the additionalProperties schema + expect(() => zodSchema?.parse({ name: 'John', isActive: true })).toThrow(); + expect(() => zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toThrow(); + }); + + it('should strip additional properties when additionalProperties is false or not specified', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + additionalProperties: false, + }; + const zodSchema = convertJsonSchemaToZod(schema); + + // Should accept the defined properties + expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 }); + + // Current implementation strips additional properties when additionalProperties is false + const objWithExtra = { name: 'John', age: 30, isActive: true }; + expect(zodSchema?.parse(objWithExtra)).toEqual({ name: 'John', age: 30 }); + + // Test with additionalProperties not specified (should behave the same) + const schemaWithoutAdditionalProps: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }; + const zodSchemaWithoutAdditionalProps = convertJsonSchemaToZod(schemaWithoutAdditionalProps); + + expect(zodSchemaWithoutAdditionalProps?.parse({ name: 'John', age: 30 })).toEqual({ + name: 'John', + age: 30, + }); + + // Current implementation strips additional properties when additionalProperties is not specified + const objWithExtra2 = { name: 'John', age: 30, isActive: true }; + expect(zodSchemaWithoutAdditionalProps?.parse(objWithExtra2)).toEqual({ + name: 'John', + age: 30, + }); + }); + + it('should handle complex nested objects with additionalProperties', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + profile: { + type: 'object', + properties: { + bio: { type: 'string' }, + }, + additionalProperties: true, + }, + }, + additionalProperties: { type: 'string' }, + }, + }, + additionalProperties: false, + }; + const zodSchema = convertJsonSchemaToZod(schema); + + const validData = { + user: { + name: 'John', + profile: { + bio: 'Developer', + location: 'New York', // Additional property allowed in profile + website: 'https://example.com', // Additional property allowed in profile + }, + role: 'admin', // Additional property of type string allowed in user + level: 'senior', // Additional property of type string allowed in user + }, + }; + + expect(zodSchema?.parse(validData)).toEqual(validData); + + // Current implementation strips additional properties at the top level + // when additionalProperties is false + const dataWithExtraTopLevel = { + user: { name: 'John' }, + extraField: 'not allowed', // This should be stripped + }; + expect(zodSchema?.parse(dataWithExtraTopLevel)).toEqual({ user: { name: 'John' } }); + + // Should reject additional properties in user that don't match the string type + expect(() => + zodSchema?.parse({ + user: { + name: 'John', + age: 30, // Not a string + }, + }), + ).toThrow(); + }); + }); + describe('empty object handling', () => { it('should return undefined for empty object schemas when allowEmptyObject is false', () => { const emptyObjectSchemas = [ diff --git a/packages/data-provider/src/zod.ts b/packages/data-provider/src/zod.ts index aa694fe148..cc4cb8a368 100644 --- a/packages/data-provider/src/zod.ts +++ b/packages/data-provider/src/zod.ts @@ -7,6 +7,7 @@ export type JsonSchemaType = { properties?: Record; required?: string[]; description?: string; + additionalProperties?: boolean | JsonSchemaType; }; function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean { @@ -72,7 +73,20 @@ export function convertJsonSchemaToZod( } else { objectSchema = objectSchema.partial(); } - zodSchema = objectSchema; + + // Handle additionalProperties for open-ended objects + if (schema.additionalProperties === true) { + // This allows any additional properties with any type + zodSchema = objectSchema.passthrough(); + } else if (typeof schema.additionalProperties === 'object') { + // For specific additional property types + const additionalSchema = convertJsonSchemaToZod( + schema.additionalProperties as JsonSchemaType, + ); + zodSchema = objectSchema.catchall(additionalSchema as z.ZodType); + } else { + zodSchema = objectSchema; + } } else { zodSchema = z.unknown(); }