Compare commits

...

157 Commits

Author SHA1 Message Date
4413c311e9 Refactor docker-compose.yml for consistency and add new services; update librechat.yaml with additional model references and configuration adjustments.
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2026-01-14 22:18:48 +00:00
35a1357203 Update FIRECRAWL_API_URL and baseURL in librechat.yaml to new endpoints, and adjust model references for consistency.
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2026-01-13 23:43:37 +00:00
f795be4274 Update instructions in librechat.yaml to clarify memory management role, emphasizing accuracy without embellishment.
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-29 15:16:40 +00:00
4fd6254e93 Update model in librechat.yaml from 'gemini-2.5-flash-lite' to 'qwen3-vl-30b-a3b-instruct' for enhanced memory management capabilities.
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-26 19:40:51 +00:00
aa95dc534d Update volume paths in deploy-compose.swarm.yml to reflect new directory structure
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-26 19:27:59 +00:00
3643d50f1a Update librechat.yaml to change provider from 'bifrost' to 'litellm' and ensure model reference is consistent
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-10 02:10:31 +00:00
8b9f9f0a74 Remove deprecated 'depends_on' entries in deploy-compose.swarm.yml and add healthcheck configurations for MongoDB and MeiliSearch services.
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-10 02:01:34 +00:00
271a574f46 Update configuration paths in librechat.yaml and stack.env for container compatibility
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-10 01:54:39 +00:00
3787541733 Add healthcheck configuration to deploy-compose.swarm.yml for improved service monitoring
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-10 01:50:28 +00:00
dd632d05e3 Update librechat.yaml to replace 'bifrost' with 'litellm' and adjust model references accordingly
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-09 20:23:19 +00:00
3c02a8ab23 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 13:45:07 +00:00
907d676d39 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 13:41:23 +00:00
4a96a9675c .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 13:39:02 +00:00
29007a7a43 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 10:50:11 +00:00
275adaddc3 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 08:54:22 +00:00
06e3dae085 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 08:40:54 +00:00
d04144933d .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 08:01:03 +00:00
411aa946ee .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 07:11:52 +00:00
2a8f455356 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 07:06:35 +00:00
f7cdbcb4d4 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 06:58:25 +00:00
00c78c88d2 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:47:21 +00:00
471a0f90b5 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:41:07 +00:00
5b9b55c24d x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:17:45 +00:00
75aba0413f x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:15:41 +00:00
448831f72b .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:13:56 +00:00
5818b372a3 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 05:04:18 +00:00
f7456dde5e x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:59:26 +00:00
0ab841674c x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:54:23 +00:00
a51e980608 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:48:25 +00:00
94e5bbdb8a .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:47:54 +00:00
d06426775b x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:43:35 +00:00
3d31925216 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:42:30 +00:00
04e54c6f6e .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:40:08 +00:00
ca97cdbfba xc
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:23:29 +00:00
c20e6c2637 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:21:05 +00:00
25308c2392 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 04:18:28 +00:00
4437771a12 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:43:15 +00:00
d52b5d7d0e .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:31:45 +00:00
307c2a4cd4 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:29:06 +00:00
978f6021de .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:17:52 +00:00
48a6f28e59 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:11:19 +00:00
77d66a0bba .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:09:13 +00:00
849586d577 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:07:20 +00:00
f9cadcca3d .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 03:02:03 +00:00
e1a5b52d07 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:57:08 +00:00
1039ea153d .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:34:54 +00:00
c5174b402c .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:31:43 +00:00
11d8666a4d .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:09:33 +00:00
78e8185439 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:04:40 +00:00
806381a992 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 02:03:34 +00:00
6db5512b84 .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:58:41 +00:00
59026f3be5 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:52:47 +00:00
48910c8da2 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:46:24 +00:00
f812570a23 x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:44:39 +00:00
ba24c5135c .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:30:54 +00:00
b2fd9561df .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:21:54 +00:00
13855ba7fc x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:15:30 +00:00
6fc77f847c x
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 01:02:23 +00:00
3b30e8723c .
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 00:07:04 +00:00
cb51b7e0ab git
Some checks failed
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
2025-12-05 00:06:55 +00:00
Danny Avila
f55bd6f99b 📂 refactor: File Type Inference for Frontend File Validation (#10807)
- Introduced `inferMimeType` utility to improve MIME type detection for uploaded files, including support for HEIC and HEIF formats.
- Updated DragDropModal to utilize the new inference logic for validating file types, ensuring compatibility with various document upload providers.
- Added comprehensive tests for `inferMimeType` to cover various scenarios, including handling of unknown extensions and preserving browser-provided types.
2025-12-04 14:24:10 -05:00
Danny Avila
754b495fb8 🔑 fix: Gemini Custom Endpoint Auth. for OAI-Compatible API (#10806)
* 🔧 fix: Gemini as Custom Endpoint Auth. Error for OAI-compatible API

* refactor: Google Compatibility in OpenAI Config

- Added a test to ensure `googleSearch` is filtered out when `web_search` is only present in `modelOptions`, not in `addParams` or `defaultParams`.
- Updated `transformToOpenAIConfig` to preserve `googleSearch` tools if `web_search` is explicitly enabled via `addParams` or `defaultParams`.
- Refactored the filtering logic for Google-specific tools to accommodate the new behavior.
2025-12-04 14:09:42 -05:00
Danny Avila
2d536dd0fa 📦 refactor: Request Message Sanitization for Smaller Final Response (#10792)
* refactor: implement sanitizeFileForTransmit and sanitizeMessageForTransmit functions for smaller payload to client transmission

* refactor: enhance sanitizeMessageForTransmit to preserve empty files array and avoid mutating original message

* refactor: update sanitizeMessageForTransmit to ensure immutability of files array and improve test clarity
2025-12-03 14:26:49 -05:00
Danny Avila
711d21365d 🪟 refactor: Portal Control for Shared View Settings and Google Balance Support (#10786)
* feat: Add support for Google model endpoint in balance check

* feat: Add portal prop to ThemeSelector and LangSelector in ShareView
2025-12-03 10:29:23 -05:00
Danny Avila
8bdc808074 refactor: Optimize & Standardize Tokenizer Usage (#10777)
* refactor: Token Limit Processing with Enhanced Efficiency

- Added a new test suite for `processTextWithTokenLimit`, ensuring comprehensive coverage of various scenarios including under, at, and exceeding token limits.
- Refactored the `processTextWithTokenLimit` function to utilize a ratio-based estimation method, significantly reducing the number of token counting function calls compared to the previous binary search approach.
- Improved handling of edge cases and variable token density, ensuring accurate truncation and performance across diverse text inputs.
- Included direct comparisons with the old implementation to validate correctness and efficiency improvements.

* refactor: Remove Tokenizer Route and Related References

- Deleted the tokenizer route from the server and removed its references from the routes index and server files, streamlining the API structure.
- This change simplifies the routing configuration by eliminating unused endpoints.

* refactor: Migrate countTokens Utility to API Module

- Removed the local countTokens utility and integrated it into the @librechat/api module for centralized access.
- Updated various files to reference the new countTokens import from the API module, ensuring consistent usage across the application.
- Cleaned up unused references and imports related to the previous countTokens implementation.

* refactor: Centralize escapeRegExp Utility in API Module

- Moved the escapeRegExp function from local utility files to the @librechat/api module for consistent usage across the application.
- Updated imports in various files to reference the new centralized escapeRegExp function, ensuring cleaner code and reducing redundancy.
- Removed duplicate implementations of escapeRegExp from multiple files, streamlining the codebase.

* refactor: Enhance Token Counting Flexibility in Text Processing

- Updated the `processTextWithTokenLimit` function to accept both synchronous and asynchronous token counting functions, improving its versatility.
- Introduced a new `TokenCountFn` type to define the token counting function signature.
- Added comprehensive tests to validate the behavior of `processTextWithTokenLimit` with both sync and async token counting functions, ensuring consistent results.
- Implemented a wrapper to track call counts for the `countTokens` function, optimizing performance and reducing unnecessary calls.
- Enhanced existing tests to compare the performance of the new implementation against the old one, demonstrating significant improvements in efficiency.

* chore: documentation for Truncation Safety Buffer in Token Processing

- Added a safety buffer multiplier to the character position estimates during text truncation to prevent overshooting token limits.
- Updated the `processTextWithTokenLimit` function to utilize the new `TRUNCATION_SAFETY_BUFFER` constant, enhancing the accuracy of token limit processing.
- Improved documentation to clarify the rationale behind the buffer and its impact on performance and efficiency in token counting.
2025-12-02 12:22:04 -05:00
Danny Avila
b2387cc6fa 📦 chore: Bump @librechat/agents to v3.0.36 (#10775) 2025-12-02 10:35:19 -05:00
Danny Avila
28bdd0dfa6 🧊 refactor: iconURL Handling in Submission Flow (#10772)
- Updated `useChatFunctions` to derive `iconURL` from conversation data before parsing
- Modified `parseCompactConvo` to explicitly omit `iconURL` from parsed conversations, reinforcing security measures around URL handling.
2025-12-02 09:48:41 -05:00
Danny Avila
1477da4987 🖥️ feat: Add Proxy Support for Tavily API Tool (#10770)
* 🖥️ feat: Add Proxy Support for Tavily API Tool

- Integrated ProxyAgent from undici to enable proxy support for API requests in TavilySearch and TavilySearchResults.
- Updated fetch options to conditionally include the proxy configuration based on the environment variable, enhancing flexibility for network requests.

* ci: TavilySearchResults with Proxy Support Tests

- Added tests to verify the integration of ProxyAgent for API requests in TavilySearchResults.
- Implemented conditional logic to check for the PROXY environment variable, ensuring correct usage of ProxyAgent based on its presence.
- Updated test setup to clear mocks before each test for improved isolation and reliability.
2025-12-02 09:11:56 -05:00
Danny Avila
ef5540f278 🔐 refactor: MCP User Variable Description Rendering (#10769)
* refactor: Add back user variable descriptions for MCP under input and not as Tooltips

- Integrated DOMPurify to sanitize HTML content in user variable descriptions, ensuring safe rendering of links and formatting.
- Updated the AuthField component to display sanitized descriptions, enhancing security and user experience.
- Removed TooltipAnchor in favor of direct label rendering for improved clarity.

* 📦 chore: Update `dompurify` to v3.3.0 in package dependencies

- Added `dompurify` version 3.3.0 to `package.json` and `package-lock.json` for improved HTML sanitization.
- Updated existing references to `dompurify` to ensure consistency across the project.

* refactor: Update tooltip styles for sanitized description in AuthField component
2025-12-02 09:03:22 -05:00
Danny Avila
745c299563 📦 chore: Bump nodemailer to v7.0.11 (#10762) 2025-12-02 00:50:15 -05:00
Danny Avila
3b35fa53d9 📦 chore: Bump mdast-util-to-hast to v13.2.1 (#10761) 2025-12-02 00:26:15 -05:00
Danny Avila
01413eea3d 🛡️ feat: Add Middleware for JSON Parsing and Prompt Group Updates (#10757)
* 🗨️ fix: Safe Validation for Prompt Updates

- Added `safeValidatePromptGroupUpdate` function to validate and sanitize prompt group update requests, ensuring only allowed fields are processed and sensitive fields are stripped.
- Updated the `patchPromptGroup` route to utilize the new validation function, returning appropriate error messages for invalid requests.
- Introduced comprehensive tests for the validation logic, covering various scenarios including allowed and disallowed fields, enhancing overall request integrity and security.
- Created a new schema file for prompt group updates, defining validation rules and types for better maintainability.

* 🔒 feat: Add JSON parse error handling middleware
2025-12-02 00:10:30 -05:00
Danny Avila
6fa94d3eb8 🔐 fix: Secure iconURL Handling (#10753)
* 🔒 fix: `iconURL` in conversation parsing

- Updated the `buildEndpointOption` middleware to derive `iconURL` from model specs when not provided by the client, improving security by preventing malicious URLs.
- Modified the `parseCompactConvo` function to strip `iconURL` from conversation inputs, ensuring it is only set server-side.
- Added comprehensive tests to validate the stripping of `iconURL` across various endpoint types, enhancing overall input sanitization.

*  feat: Add ESLint rule for unused variables

- Introduced a new ESLint rule to warn about unused variables, allowing for better code quality and maintainability.
- Configured the rule to ignore variables and arguments that start with an underscore, accommodating common coding practices.
2025-12-01 17:41:39 -05:00
Danny Avila
4202db1c99 🤖 feat: Tool Calling Support for DeepSeek V3.2 + OpenRouter Reasoning (#10752)
* 🔧 chore: Update @librechat/agents to version 3.0.35

*  feat: Add DeepSeek Model Pricing and Token Handling

- Introduced pricing and token limits for 'deepseek-chat' and 'deepseek-reasoner' models, including prompt and completion rates.
- Enhanced tests to validate pricing and token limits for DeepSeek models, ensuring correct handling of model variations and provider prefixes.
- Updated cache multipliers for DeepSeek models to reflect new pricing structure.
- Improved max output token handling for DeepSeek models, ensuring consistency across different endpoints.
2025-12-01 14:27:08 -05:00
Danny Avila
026890cd27 🛡️ fix: Improve Error Handling and Null Safety in SSE Event Processing (#10751)
* 🔧 fix: Handle null content parts in message processing

- Added checks to filter out null content parts in various message handling functions, ensuring robustness against undefined values.
- Updated the `extractMessageContent`, `useContentHandler`, `useEventHandlers`, and `useStepHandler` hooks to prevent errors caused by null parts.
- Enhanced the `getAllContentText` utility to only include valid content types, improving overall message integrity.

* 🔧 fix: Enhance error handling in event and SSE handlers

- Wrapped critical sections in try-catch blocks within `useEventHandlers` and `useSSE` hooks to improve error management and prevent application crashes.
- Added console error logging for better debugging and tracking of issues during message processing and conversation aborting.
- Ensured that UI states like `setIsSubmitting` and `setShowStopButton` are correctly updated in case of errors, maintaining a consistent user experience.

* 🔧 fix: Filter out null and empty content in message export

- Enhanced the `useExportConversation` hook to filter out null content parts and empty strings during message processing, ensuring only valid content is included in the export.
- This change improves the integrity of exported conversations by preventing unnecessary empty entries in the output.
2025-12-01 14:05:50 -05:00
Danny Avila
6c0aad423f 📐 refactor: Exclude Params from OAI Reasoning Models (#10745)
* 📐 refactor: Exclude Params from OAI Reasoning Models

- Introduced a new test suite for `getOpenAILLMConfig` covering various model configurations, including basic settings, reasoning models, and web search functionality.
- Validated parameter handling for different models, ensuring correct exclusions and conversions, particularly for temperature and max_tokens.
- Enhanced tests for default and additional parameters, drop parameters, and verbosity handling, ensuring robust coverage of the configuration logic.

* ci: Update OpenAI model version in configuration tests

- Changed model references from 'gpt-5' to 'gpt-4' across multiple test cases in the `getOpenAIConfig` function.
- Adjusted related parameter handling to ensure compatibility with the updated model version, including maxTokens and temperature settings.
- Enhanced test coverage for model options and their expected configurations.
2025-12-01 12:00:54 -05:00
Joel Hirzel
774ebd1eaa 🔨 fix: Update expiresAt timestamp for tokens (#10740) 2025-12-01 09:45:57 -05:00
Danny Avila
d5d362e52b 📬 refactor: Normalize Email Handling in User Methods (#10743)
- Updated the `findUser` method to normalize email fields to lowercase and trimmed whitespace for case-insensitive matching.
- Enhanced the `normalizeEmailInCriteria` function to handle email normalization in search criteria, including `` conditions.
- Added tests to ensure email normalization works correctly across various scenarios, including case differences and whitespace handling.
2025-12-01 09:41:25 -05:00
Danny Avila
d7ce19e15a 🤖 feat: Latest Grok Model Pricing & Context Rates (#10727)
* 🤖 feat: Latest Grok Model Pricing & Context Rates

- Introduced 'grok-4-fast', 'grok-4-1-fast', and 'grok-code-fast' models with their respective prompt and completion rates.
- Enhanced unit tests to validate prompt and completion rates for the new models, including variations with prefixes.
- Updated token limits for the new models in the tokens utility, ensuring accurate handling in tests.

* 🔧 refactor: Optimize JSON Export Logic in useExportConversation Hook

Updated the export logic to create a Blob from the JSON string before downloading, improving compatibility and performance for file downloads. This change enhances the handling of deeply nested exports while maintaining the file size reduction achieved in previous updates.
2025-11-30 17:10:26 -05:00
Danny Avila
2ccaf6be6d ⬇️ refactor: Improve Conversation JSON Export (#10726)
Updated the export logic in the useExportConversation hook to use JSON.stringify without indentation, reducing file size for deeply nested exports. Additionally, ensured safe access to text content by providing a fallback for undefined values.
2025-11-30 16:57:46 -05:00
Joel Hirzel
90f0bcde44 🖼️ fix: resolve stuck pixel animation for image generation (#10716) 2025-11-29 00:23:22 +01:00
Danny Avila
801c95a829 🦙 fix: Ollama Provider Handling (#10711)
* 🔧 fix: Correct URL Construction in fetchModels Function

Updated the URL construction in the fetchModels function to ensure proper formatting by removing trailing slashes from the base URL. This change prevents potential issues with API endpoint calls.

* 🔧 fix: Remove OLLAMA from Known Custom Providers

Updated the isKnownCustomProvider function and providerConfigMap to exclude OLLAMA as a known custom provider, streamlining the provider checks and configurations.

* 🔧 test: Enhance fetchModels Tests for URL Construction

Added new test cases to validate the URL construction in the fetchModels function, ensuring it handles trailing slashes correctly and appends query parameters as expected. This improves the robustness of the API endpoint calls.

* chore: remove ollama provider-specific handling

* chore: Refactor imports to use isUserProvided from @librechat/api
2025-11-28 11:14:13 -05:00
Linus Gasser
872dbb4151 🪙 refactor: Remove Use of CHECK_BALANCE in Balance Scripts (#10702)
config/set_balance.js and config/add_balance.js still use the CHECK_BALANCE variable.
This PR makes them use the getBalanceConfig from the api.
2025-11-28 11:11:26 -05:00
Danny Avila
cb2bee19b7 🐋 chore: Add Workflow for Docker Dev Staging Images Build 2025-11-27 12:26:37 -05:00
Danny Avila
961d3b1d3b 📦 chore: Bump @librechat/agents to v3.0.33 (#10704) 2025-11-27 12:04:38 -05:00
Danny Avila
f0f81945fb v0.8.1-rc2 (#10688)
*  v0.8.1-rc2

- Updated version numbers in Dockerfile, Dockerfile.multi, package.json, and various package.json files for client, api, and data-provider.
- Adjusted appVersion in Chart.yaml and constants in config.ts to reflect the new version.
- Incremented versions for @librechat/api, @librechat/client, and librechat-data-provider packages.

* chore: Update Chart version to 1.9.3

- Incremented the chart version in Chart.yaml to reflect the latest changes.
2025-11-26 11:40:08 -05:00
Danny Avila
bdc65c5713 🪵 chore: Clean up Debug Logs in OpenID Token Extraction (#10687)
Removed unnecessary debug logging statements in the extractOpenIDTokenInfo function to streamline the code and improve readability. This change enhances the clarity of the function's logic without altering its functionality.
2025-11-26 11:29:10 -05:00
Dustin Healy
07ed2cfed4 🏷️ fix: Editing Bookmark Descriptions (#10685)
* fix: exclude the currently edited bookmark from duplicate checks

* ci: Add comprehensive tests for BookmarkForm component

- Introduced a new test suite for the BookmarkForm component to validate bookmark editing functionality.
- Implemented tests for various scenarios including editing descriptions, renaming tags, and handling duplicate tags.
- Ensured proper feedback through toasts for error cases and successful submissions.
- Mocked necessary hooks and context to isolate component behavior during tests.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-26 11:15:31 -05:00
Zihao Zhou
5b8f0cba04 📄 refactor: Add Provider Fallback for Media Encoding using Client Endpoint (#10656)
When using direct endpoints (e.g., Google) instead of Agents, `this.options.agent` is undefined, causing provider detection to fail. This resulted in "Unknown content type document" errors for Google/Gemini PDF uploads.

Added `?? this.options.endpoint` fallback in addDocuments(), addVideos(), and addAudios() methods to ensure correct provider detection for all endpoint types.
2025-11-25 17:07:37 -05:00
Dustin Healy
8b7af65265 🪄 style: Improved Input Collapse UI (#10659)
* feat: shift collapse chevron inside ChatForm input area

* feat: add soft gradient on bottom of collapsed text input so there isn't a hard cut off when text overflows

* feat: add better scroll bar behavior for main chat input

* fix: smooth out purple gradient for temporary chats

* feat: better colors for gradient

* feat: use blur instead of colors

* chore: address copilot comments
2025-11-25 17:02:52 -05:00
Dustin Healy
30df16f5b5 🍞 feat: Add Toasts for Successful Conversation Deletion (#10661)
* feat: add toasts for succesful conversation deletion

* chore: address copilot comments
2025-11-25 17:02:01 -05:00
Marco Beretta
f5132a65e9 style: Update "Copy Agent" Icon for Clearer Action (#10651) 2025-11-25 15:17:15 -05:00
rossbg
959984f959 ⏱️ fix: Increase RAG API Text Parsing Timeout (#10562)
* fix: increase RAG API text parsing timeout for large files

* ci: Update text.spec.ts

---------

Co-authored-by: Rosen Simov <rosen.simov@endurosat.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-25 14:54:53 -05:00
Daniel Lew
ffcca3254e 📢 fix: Remove Side Panel Elements from Screen Reader when Hidden (#10648)
* fix: remove side panel elements from screen reader when hidden

There's both left & right side panels; elements of both of them
are hidden when dismissed. However, currently they are being hidden
by using classes to hide their UI (such as making the sidebar
zero width).

That works for visually dismissing these elements, but they can still
be viewed by a screen reader (using the tab key to jump between
interactable elements). That can be a rather confusing experience
for anyone visually impaired (such as duplicate buttons, or buttons
that do nothing).

--------

I've changed it so hidden elements are fully removed from the render.
This prevents them from being interactable via keyboard.

I leveraged Motion to duplicate the animations as they happened before.
I subtly cleaned up the animations while I was at it.

* Implemented reasonable suggestions from Copilot review
2025-11-25 13:56:32 -05:00
Danny Avila
9211d59388 🤖 feat: Claude Opus 4.5 Token Rates and Window Limits (#10653)
* 🤖 feat: Claude Opus 4.5 Token Rates and Window Limits

- Introduced new model 'claude-opus-4-5' with defined prompt and completion values in tokenValues and cacheTokenValues.
- Updated tests to validate prompt, completion, and cache rates for the new model.
- Enhanced model name handling to accommodate variations for 'claude-opus-4-5' across different contexts.
- Adjusted schemas to ensure correct max output token limits for the new model.

* ci: Add tests for "prompt-caching" beta header in Claude Opus 4.5 models

- Implemented tests to verify the addition of the "prompt-caching" beta header for the 'claude-opus-4-5' model and its variations.
- Updated future-proofing logic to ensure correct max token limits for Claude 4.x and 5.x Opus models, adjusting defaults to 64K where applicable.
- Enhanced existing tests to reflect changes in expected max token values for future Claude models.

* chore: Remove redundant max output check for Anthropic settings

- Eliminated the unnecessary check for ANTHROPIC_MAX_OUTPUT in the anthropicSettings schema, streamlining the logic for handling max output values.
2025-11-24 16:30:56 -05:00
Danny Avila
e123e5f9ec 🔗 fix: Resolve Bedrock Tool Call Streaming "Content Type Mismatch" (#10647) 2025-11-24 14:18:56 -05:00
Peter
3628619297 🛰️ fix: MCP SSE & Ping Error Handling (#10635)
Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-11-24 13:57:29 -05:00
Danny Avila
35319c1354 🔧 fix: Remove Bedrock Config Transform introduced in #9931 (#10628)
* fix: Header and Environment Variable Handling Bug from #9931

* refactor: Remove warning log for missing tokens in extractOpenIDTokenInfo function

* feat: Enhance resolveNestedObject function for improved placeholder processing

- Added a new function `resolveNestedObject` to recursively process nested objects, replacing placeholders in string values while preserving the original structure.
- Updated `createTestUser` to use `IUser` type and modified user ID generation.
- Added comprehensive unit tests for `resolveNestedObject` to cover various scenarios, including nested structures, arrays, and custom user variables.
- Improved type handling in `processMCPEnv` to ensure correct processing of mixed numeric and placeholder values.

* refactor: Remove unnecessary manipulation of Bedrock options introduced in #9931

- Eliminated the resolveHeaders function call from the getOptions method in options.js, as it was no longer necessary for processing additional model request fields.
- This change simplifies the code and improves maintainability.
2025-11-21 16:42:28 -05:00
github-actions[bot]
03955bd5cf 🌍 i18n: Update translation.json with latest translations (#10622)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-21 16:20:46 -05:00
Danny Avila
3950b9ee53 📦 chore: Update Packages for Security & Remove Unnecessary (#10620)
* 🗑️ chore: Remove @microsoft/eslint-formatter-sarif from dependencies and update ESLint CI workflow

- Removed @microsoft/eslint-formatter-sarif from package.json and package-lock.json.
- Updated ESLint CI workflow to eliminate SARIF upload logic and related environment variables.

* chore: Remove ts-jest from dependencies in jest.config and package files

* chore: Update package dependencies to latest versions

- Upgraded @rollup/plugin-commonjs from 25.0.2 to 29.0.0 across multiple packages.
- Updated rimraf from 5.0.1 to 6.1.2 in packages/api, client, data-provider, and data-schemas.
- Added new dependencies: @isaacs/balanced-match and @isaacs/brace-expansion in package-lock.json.
- Updated glob from 8.1.0 to 13.0.0 and adjusted related dependencies accordingly.

* chore: remove prettier-eslint dependency from package.json

* chore: npm audit fix

* fix: correct `getBasePath` import
2025-11-21 14:53:58 -05:00
Danny Avila
1814c81888 🕸️ fix: Minor Type Issues & Anthropic Web Search (#10618)
* fix: update @librechat/agents dependency to version 3.0.29

* chore: fix typing by replacing TUser with IUser

* chore: import order

* fix: replace TUser with IUser in run and OAuthReconnectionManager modules

* fix: update @librechat/agents dependency to version 3.0.30
2025-11-21 14:25:05 -05:00
WhammyLeaf
846e34b1d7 🗑️ fix: Remove All User Metadata on Deletion (#10534)
* remove all user metadata on deletion

* chore: import order

* fix: Update JSDoc types for deleteMessages function parameters and return value

* fix: Enhance user deletion process by removing associated data and updating group memberships

* fix: Add missing config middleware to user deletion route

* fix: Refactor agent and prompt deletion processes to bulk delete and remove associated ACL entries

* fix: Add deletion of OAuth tokens and ACL entries in user deletion process

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 12:03:26 -05:00
catmeme
7aa8d49f3a 🧭 fix: Add Base Path Support for Login/Register and Image Paths (#10116)
* fix: add basePath pattern to support login/register and image paths

* Fix linter errors

* refactor: Update import statements for getBasePath and isEnabled, and add path utility functions with tests

- Refactored imports in addImages.js and StableDiffusion.js to use getBasePath from '@librechat/api'.
- Consolidated isEnabled and getBasePath imports in validateImageRequest.js.
- Introduced new path utility functions in path.ts and corresponding unit tests in path.spec.ts to validate base path extraction logic.

* fix: Update domain server base URL in MarkdownComponents and refactor authentication redirection logic

- Changed the domain server base URL in MarkdownComponents.tsx to use the API base URL.
- Refactored the useAuthRedirect hook to utilize React Router's navigate for redirection instead of window.location, ensuring a smoother SPA experience.
- Added unit tests for the useAuthRedirect hook to verify authentication redirection behavior.

* test: Mock isEnabled in validateImages.spec.js for improved test isolation

- Updated validateImages.spec.js to mock the isEnabled function from @librechat/api, ensuring that tests can run independently of the actual implementation.
- Cleared the DOMAIN_CLIENT environment variable before tests to avoid interference with basePath resolution.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 11:25:14 -05:00
Jón Levy
ef3bf0a932 🆔 feat: Add OpenID Connect Federated Provider Token Support (#9931)
* feat: Add OpenID Connect federated provider token support

Implements support for passing federated provider tokens (Cognito, Azure AD, Auth0)
as variables in LibreChat's librechat.yaml configuration for both custom endpoints
and MCP servers.

Features:
- New LIBRECHAT_OPENID_* template variables for federated provider tokens
- JWT claims parsing from ID tokens without verification (for claim extraction)
- Token validation with expiration checking
- Support for multiple token storage locations (federatedTokens, openidTokens)
- Integration with existing template variable system
- Comprehensive test suite with Cognito-specific scenarios
- Provider-agnostic design supporting Cognito, Azure AD, Auth0, etc.

Security:
- Server-side only token processing
- Automatic token expiration validation
- Graceful fallbacks for missing/invalid tokens
- No client-side token exposure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Add federated token propagation to OIDC authentication strategies

Adds federatedTokens object to user during authentication to enable
federated provider token template variables in LibreChat configuration.

Changes:
- OpenID JWT Strategy: Extract raw JWT from Authorization header and
  attach as federatedTokens.access_token to enable {{LIBRECHAT_OPENID_TOKEN}}
  placeholder resolution
- OpenID Strategy: Attach tokenset tokens as federatedTokens object to
  standardize token access across both authentication strategies

This enables proper token propagation for custom endpoints and MCP
servers that require federated provider tokens for authorization.

Resolves missing token issue reported by @ramden in PR #9931

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Denis Ramic <denis.ramic@nfon.com>
Co-Authored-By: Claude <noreply@anthropic.com>

* test: Add federatedTokens validation tests for OIDC strategies

Adds comprehensive test coverage for the federated token propagation
feature implemented in the authentication strategies.

Tests added:
- Verify federatedTokens object is attached to user with correct structure
  (access_token, refresh_token, expires_at)
- Verify both tokenset and federatedTokens are present in user object
- Ensure tokens from OIDC provider are correctly propagated

Also fixes existing test suite by adding missing mocks:
- isEmailDomainAllowed function mock
- findOpenIDUser function mock

These tests validate the fix from commit 5874ba29f that enables
{{LIBRECHAT_OPENID_TOKEN}} template variable functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: Remove implementation documentation file

The PR description already contains all necessary implementation details.
This documentation file is redundant and was requested to be removed.

* fix: skip s256 check

* fix(openid): handle missing refresh token in Cognito token refresh response

When OPENID_REUSE_TOKENS=true, the token refresh flow was failing because
Cognito (and most OAuth providers) don't return a new refresh token in the
refresh grant response - they only return new access and ID tokens.

Changes:
- Modified setOpenIDAuthTokens() to accept optional existingRefreshToken parameter
- Updated validation to only require access_token (refresh_token now optional)
- Added logic to reuse existing refresh token when not provided in tokenset
- Updated refreshController to pass original refresh token as fallback
- Added comments explaining standard OAuth 2.0 refresh token behavior

This fixes the "Token is not present. User is not authenticated." error that
occurred during silent token refresh with Cognito as the OpenID provider.

Fixes: Authentication loop with OPENID_REUSE_TOKENS=true and AWS Cognito

* fix(openid): extract refresh token from cookies for template variable replacement

When OPENID_REUSE_TOKENS=true, the openIdJwtStrategy populates user.federatedTokens
to enable template variable replacement (e.g., {{LIBRECHAT_OPENID_ACCESS_TOKEN}}).

However, the refresh_token field was incorrectly sourced from payload.refresh_token,
which is always undefined because:
1. JWTs don't contain refresh tokens in their payload
2. The JWT itself IS the access token
3. Refresh tokens are separate opaque tokens stored in HTTP-only cookies

This caused extractOpenIDTokenInfo() to receive incomplete federatedTokens,
resulting in template variables remaining unreplaced in headers.

**Root Cause:**
- Line 90: `refresh_token: payload.refresh_token` (always undefined)
- JWTs only contain access token data in their claims
- Refresh tokens are separate, stored securely in cookies

**Solution:**
- Import `cookie` module to parse cookies from request
- Extract refresh token from `refreshToken` cookie
- Populate federatedTokens with both access token (JWT) and refresh token (from cookie)

**Impact:**
- Template variables like {{LIBRECHAT_OPENID_ACCESS_TOKEN}} now work correctly
- Headers in librechat.yaml are properly replaced with actual tokens
- MCP server authentication with federated tokens now functional

**Technical Details:**
- passReqToCallback=true in JWT strategy provides req object access
- Refresh token extracted via cookies.parse(req.headers.cookie).refreshToken
- Falls back gracefully if cookie header or refreshToken is missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: re-resolve headers on each request to pick up fresh federatedTokens

- OpenAIClient now re-resolves headers in chatCompletion() before each API call
- This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} are replaced
  with actual token values from req.user.federatedTokens
- initialize.js now stores original template headers instead of pre-resolved ones
- Fixes template variable replacement when OPENID_REUSE_TOKENS=true

The issue was that headers were only resolved once during client initialization,
before openIdJwtStrategy had populated user.federatedTokens. Now headers are
re-resolved on every request with the current user's fresh tokens.

* debug: add logging to track header resolution in OpenAIClient

* debug: log tokenset structure after refresh to diagnose missing access_token

* fix: set federatedTokens on user object after OAuth refresh

- After successful OAuth token refresh, the user object was not being
  updated with federatedTokens
- This caused template variable resolution to fail on subsequent requests
- Now sets user.federatedTokens with access_token, id_token, refresh_token
  and expires_at from the refreshed tokenset
- Fixes template variables like {{LIBRECHAT_OPENID_TOKEN}} not being
  replaced after token refresh
- Related to PR #9931 (OpenID federated token support)

* fix(openid): pass user object through agent chain for template variable resolution

Root cause: buildAgentContext in agents/run.ts called resolveHeaders without
the user parameter, preventing OpenID federated token template variables from
being resolved in agent runtime parameters.

Changes:
- packages/api/src/agents/run.ts: Add user parameter to createRun signature
- packages/api/src/agents/run.ts: Pass user to resolveHeaders in buildAgentContext
- api/server/controllers/agents/client.js: Pass user when calling createRun
- api/server/services/Endpoints/bedrock/options.js: Add resolveHeaders call with debug logging
- api/server/services/Endpoints/custom/initialize.js: Add debug logging
- packages/api/src/utils/env.ts: Add comprehensive debug logging and stack traces
- packages/api/src/utils/oidc.ts: Fix eslint errors (unused type, explicit any)

This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} and
{{LIBRECHAT_USER_OPENIDID}} are properly resolved in both custom endpoint
headers and Bedrock AgentCore runtime parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: remove debug logging from OpenID token template feature

Removed excessive debug logging that was added during development to make
the PR more suitable for upstream review:

- Removed 7 debug statements from OpenAIClient.js
- Removed all console.log statements from packages/api/src/utils/env.ts
- Removed debug logging from bedrock/options.js
- Removed debug logging from custom/initialize.js
- Removed debug statement from AuthController.js

This reduces the changeset by ~50 lines while maintaining full functionality
of the OpenID federated token template variable feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test(openid): add comprehensive unit tests for template variable substitution

- Add 34 unit tests for OIDC token utilities (oidc.spec.ts)
- Test coverage for token extraction, validation, and placeholder processing
- Integration tests for full OpenID token flow
- All tests pass with comprehensive edge case coverage

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>

* test: fix OpenID federated tokens test failures

- Add serverMetadata() mock to openid-client mock configuration
  * Fixes TypeError in openIdJwtStrategy.js where serverMetadata() was being called
  * Mock now returns jwks_uri and end_session_endpoint as expected by the code

- Update outdated initialize.spec.js test
  * Remove test expecting resolveHeaders call during initialization
  * Header resolution was refactored to be deferred until LLM request time
  * Update test to verify options are returned correctly with useLegacyContent flag

Fixes #9931 CI failures for backend unit tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: fix package-lock.json conflict

* chore: sync package-log with upstream

* chore: cleanup

* fix: use createSafeUser

* fix: fix createSafeUser signature

* chore: remove comments

* chore: purge comments

* fix: update Jest testPathPattern to testPathPatterns for Jest 30+ compatibility

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Denis Ramic <denis.ramic@nfon.com>
Co-authored-by: kristjanaapro <kristjana@apro.is>

chore: import order and add back JSDoc for OpenID JWT callback
2025-11-21 09:51:11 -05:00
michnovka
040d083088 feat: Prevent Screen Sleep During Response Generation (#10597)
* feat: prevent screen sleep during response generation

* refactor: screen wake lock functionality during response generation

* chore: import order

* chore: reorder import statements in WakeLockManager component

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-21 09:14:32 -05:00
NalinNair
5ac9ac57cc 📑 refactor: Skip H1 Rendering for Falsy Header Values in AuthLayout (#10606) 2025-11-20 16:57:36 -05:00
Danny Avila
b49545d916 🪂 refactor: MCP Server Init Fallback (#10608)
* 🌿 refactor: MCP Server Init and Registry with Fallback Configs

* chore: Redis Cache Flushing for Cluster Support
2025-11-20 16:47:00 -05:00
Theo N. Truong
1e4c255351 🔒 fix: Disable Redis leader-only mode for shared app and user servers (#10605)
Resolving: https://github.com/danny-avila/LibreChat/discussions/10598
2025-11-20 14:00:43 -05:00
Dustin Healy
dfcaff9b00 📷 fix: Use 'media' type for Google multimodal attachments (#10586)
* fix: change google multimodal attachments to use type: 'media'

* chore: Update @librechat/agents to version 3.0.27 in package.json and package-lock.json

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-19 18:31:05 -05:00
Danny Avila
ba974604b1 🔒 feat: Implement Cross-Platform IP Validation Functionality
* Added a new `isIP` function for validating IP addresses in both Node.js and browser environments, replacing the previous reliance on the Node.js `net` module.
* Updated domain extraction and validation logic to utilize the new `isIP` function, ensuring consistent IP validation across the application.
* Enhanced handling of IPv4 and IPv6 addresses, including proper formatting for URLs.
2025-11-19 17:48:29 -05:00
Danny Avila
086e9a92dc 🔒 feat: Enhance Actions SSRF Protection with Comprehensive IP and Domain Validation (#10583)
* 🔒 feat: Enhance SSRF Protection with Comprehensive IP and Domain Validation

* Added extensive tests for validating IP addresses and domains to prevent SSRF attacks, including checks for internal, private, and link-local addresses.
* Improved domain validation logic to handle various edge cases, ensuring only legitimate requests are processed.
* Implemented security measures against common cloud provider metadata access and internal service exploitation.
* Updated existing tests to reflect changes in validation logic and ensure robust security coverage.

* chore: cleanup comments

* 🔒 feat: Improve Domain Validation Logic for Enhanced Security

* Added logic to extract and normalize hostnames from client-provided domains, including handling of URLs and IP addresses.
* Implemented checks using Node.js's net module to validate IP addresses, ensuring robust domain validation.
* Updated existing validation conditions to enhance security against potential SSRF attacks.

* feat: Additional Protocol Checks and IPv6 Support

* Added tests to reject unsupported protocols (FTP, WebSocket, file) in client domains to strengthen SSRF protection.
* Improved domain extraction logic to preserve brackets for IPv6 addresses, ensuring correct URL formatting.
* Updated validation logic to handle various edge cases for client-provided domains, enhancing overall security.

* feat: Expand Domain Validation Tests for Enhanced SSRF Protection

* Added comprehensive tests for handling various URL formats, including IPv6 addresses, authentication credentials, and special characters in paths.
* Implemented additional validation scenarios for client domains, covering edge cases such as malformed URLs, empty strings, and unsupported protocols.
* Enhanced handling of internationalized domain names and localhost variations to ensure robust domain extraction and validation.
2025-11-19 17:42:17 -05:00
Danny Avila
9f2fc25bde 🔬 refactor: Prevent Automatic MCP Server UI Deselection (#10588)
* chore: Add experimental backend server for multi-pod simulation

* Introduced a new backend script (`experimental.js`) to manage a clustered server environment with Redis cache flushing on startup.
* Updated `package.json` to include a new script command for the experimental backend.
* This setup aims to enhance scalability and performance for production environments.

* refactor: Remove server disconnection handling logic from useMCPServerManager
2025-11-19 17:10:25 -05:00
Daniel Lew
014eb10662 📢 fix: Resolved Screen Reader Issues with TooltipAnchor (#10580)
TooltipAnchor was automatically adding an `aria-describedby`
tag which often duplicated the labeling already present inside
of the anchor. E.g., the screen reader might say
"New Chat, New Chat, button" instead of just "New Chat, button."

I've removed the TooltipAnchor's automatic `aria-describedby` and
worked to make sure that anyone using TooltipAnchor properly defines
its labeling.
2025-11-19 17:10:10 -05:00
Danny Avila
8b9afd5965 🤖 feat: Gemini 3 Support (#10584)
* feat: Add support for  model in token configurations and tests

* chore: Update @librechat/agents to version 3.0.26 in package.json and package-lock.json
2025-11-19 15:05:37 -05:00
Danny Avila
4c2719a37e 🛡️ chore: Enhance Agents Error Handling via @librechat/agents@v3.0.25 (#10577)
* 🔧 fix: Enhance error handling for agents system in uncaughtException logger

* Added specific logging for errors originating from the agents system to improve debugging and maintain application stability.

* 📦 chore: Update dependencies for `@librechat/agents` and related packages to v3.0.25 and improve version consistency across modules
2025-11-19 09:20:44 -05:00
Linus Gasser
e1fdd5b7e8 🚩 feat: Add --provider flag to create-user script (#10572)
As we're using google authentication without automatic sign-up, we need
a way to pass the provider to the user creation.
2025-11-19 09:05:00 -05:00
Anthony Quéré
69c6d023e1 📨 feat: Pass Custom Headers to Model Discovery (v1/models) (#10564) 2025-11-19 08:49:51 -05:00
Danny Avila
ce1812b7c2 🐛 fix: Error Handling in MCP Tool List Controller (#10570)
* 🔧 fix: Handle errors when fetching server tools and log missing tools in MCP tools controller, to prevent all MCP tools from not getting listed

* 🔧 fix: Remove trailing colons from error messages in MCPConnection class

* chore: Update test command patterns in package.json for cache integration tests
2025-11-18 18:28:57 -05:00
Danny Avila
4a13867a47 📦 chore: Bump @librechat/agents to v3.0.22 2025-11-18 13:09:41 -05:00
Danny Avila
8f887f480d 🔧 fix: Catch Errors in ToolEndHandler and Pass Logger (#10565) 2025-11-18 13:00:33 -05:00
Joseph Licata
3dd827e9d2 🔧 refactor: Update Avatar component to improve file selection handling (#10555)
* Refactored `openFileDialog` to use `useCallback` for better performance.
* Introduced `handleSelectFileClick` to manage file selection click events, enhancing user interaction.
2025-11-17 17:11:48 -05:00
Marco Beretta
8907bd5d7c 👤 feat: Agent Avatar Removal and Decouple upload/reset from Agent Updates (#10527)
*  feat: Enhance agent avatar management with upload and reset functionality

*  feat: Refactor AvatarMenu to use DropdownPopup for improved UI and functionality

*  feat: Improve avatar upload handling in AgentPanel to suppress misleading "no changes" toast

*  feat: Refactor toast message handling and payload composition in AgentPanel for improved clarity and functionality

*  feat: Enhance agent avatar functionality with upload, reset, and validation improvements

*  feat: Refactor agent avatar upload handling and enhance related components for improved functionality and user experience

* feat(agents): tighten ACL, harden GETs/search, and sanitize action metadata
stop persisting refreshed S3 URLs on GET; compute per-response only
enforce ACL EDIT on revert route; remove legacy admin/author/collab checks
sanitize action metadata before persisting during duplication (api_key, oauth_client_id, oauth_client_secret)
escape user search input, cap length (100), and use Set for public flag mapping
add explicit req.file guard in avatar upload; fix empty catch lint; remove unused imports

* feat: Remove outdated avatar-related translation keys

* feat: Improve error logging for avatar updates and streamline file input handling

* feat(agents): implement caching for S3 avatar refresh in agent list responses

* fix: replace unconventional 'void e' with explicit comment to clarify intentionally ignored error

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(agents): enhance avatar handling and improve search functionality

* fix: clarify intentionally ignored error in agent list handler

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-17 17:04:01 -05:00
Danny Avila
c0cb48256e 🤖 refactor: Improve Agent Handoff Context Tracking (#10553)
* chore: update @librechat/agents dependency to version 3.0.18

* refactor: add optional metadata field to message schema and types

* chore: update @librechat/agents to v3.0.19

* refactor: update return type of sendCompletion method to include metadata

* chore: linting

* chore: update @librechat/agents dependency to v3.0.20

* refactor: implement agent labeling for conversation history in multi-agent scenarios

* refactor: improve error handling for capturing agent ID map in AgentClient

* refactor: clear agentIdMap and related properties during client disposal to prevent memory leaks

* chore: update sendCompletion method for FakeClient to return an object with completion and metadata fields
2025-11-17 16:57:51 -05:00
Danny Avila
bdc47dbe47 fix: Async Model End Events, Await Tool Call and Dispatch Handling (#10552) 2025-11-17 16:37:40 -05:00
Danny Avila
49c57b27fd fix: createFileSearchTool to return tuples for error messages (#10547) 2025-11-17 13:12:16 -05:00
Danny Avila
1b2f1ff09b 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior (#10537)
* 🚪 fix: ArtifactsPanel and SidePanel Rendering and Collapsing Behavior

* refactor: improve side panel behavior when artifacts panel renders null
2025-11-16 13:55:35 -05:00
Adaptive Garage
0a2f40cc50 🪣 feat: Init Containers and Custom ConfigMaps Support in Helm Chart (#10525) 2025-11-16 12:03:34 -05:00
Theo N. Truong
8c531b921e 🐛 fix: Redis Cluster Bug + 🧪 Enhance Test Coverage (#10518)
*  feat: Implement scanIterator method for Redis cluster client
This resolves the bug where `ServerConfigsCacheRedis#getAll` returns an empty object when a Redis Cluster (instead of a single node server is used)

*  feat: Update cache integration tests for Redis cluster support
2025-11-16 11:58:52 -05:00
Danny Avila
f228f2a91d 📦 chore: Jest & Eslint Package Updates (#10536)
* chore: update js-yaml to v4.1.1

* chore: update eslint to v9.39.1 in package.json and package-lock.json

* chore: update prettier-eslint to v16.4.2 in package.json and package-lock.json

* chore: update @eslint/eslintrc to v3.3.1 in package.json and package-lock.json

* chore: update ts-jest to v29.4.5 in package.json and package-lock.json

* chore: update jest to version 30.2.0 across multiple packages and update related dependencies
2025-11-16 11:55:18 -05:00
github-actions[bot]
59b57623f7 🌍 i18n: Update translation.json with latest translations (#10519)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-16 11:46:16 -05:00
Danny Avila
61c4736125 📜 chore: Update deployed-update.js to use 'docker compose' syntax 2025-11-14 13:53:22 -05:00
Danny Avila
d844754edf 📼 fix: Remove Legacy File Upload for Non-agents (#10517) 2025-11-14 13:17:17 -05:00
Danny Avila
6522789f5b 🤖 feat: GPT-5.1 (#10491) 2025-11-14 12:28:20 -05:00
Marco Beretta
e71c48ec3d 🎨 fix: Correct Read-Only State Logic in Code Editor (#10508)
*  style: Update ThinkingButton container background color for improved visibility

*  style: Refactor Clipboard icon rendering for improved readability

*  style: Simplify readOnly state initialization and update logic in ArtifactCodeEditor

*  style: Update Thinking component background color for improved aesthetics

* Update client/src/components/Chat/Messages/MinimalHoverButtons.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:27:51 -05:00
Maxim
f6868fc851 🔤 fix: Replace Translation Keys with Localized Text (#10486)
Co-authored-by: Max Dutkin <dutkinm@corning.com>
2025-11-13 17:01:24 -05:00
Marco Beretta
c2505d2bc9 🤝 feat: View Artifacts in Shared Conversations (#10477)
* feat: Integrate logger for MessageIcon component

* feat: Enhance artifact sharing functionality with updated path checks and read-only state management

* feat: Refactor Thinking and Reasoning components for improved structure and styling

* feat: Enhance artifact sharing with context value management and responsive layout

* feat: Enhance ShareView with theme and language management features

* feat: Improve ThinkingButton accessibility and styling for better user interaction

* feat: Introduce isArtifactRoute utility for route validation in Artifact components

* feat: Add latest message text extraction in SharedView for improved message display

* feat: Update locale handling in SharedView for dynamic date formatting

* feat: Refactor ArtifactsContext and SharedView for improved context handling and styling adjustments

* feat: Enhance artifact panel size management with local storage integration

* chore: imports

* refactor: move ShareArtifactsContainer out of ShareView

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 16:59:46 -05:00
Danny Avila
cabc8afeac 🔧 fix: Await MCP Instructions and Filter Malformed Tool Calls (#10485)
* fix: Await MCP instructions formatting in AgentClient

* fix: don't render or aggregate malformed tool calls

* fix: implement filter for malformed tool call content parts and add tests
2025-11-13 14:17:47 -05:00
github-actions[bot]
aff3cd3667 🌍 i18n: Update translation.json with latest translations (#10481)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-13 10:22:19 -05:00
Dustin Healy
c9ee0f138a 🪨 feat: Add Bedrock Prompt Caching Support (#8271)
* feat: Add Bedrock Cache Control Functionality

- fix: Update Bedrock Cache Control to Require cachePoint as a Separate Content Block

- Modified the addBedrockCacheControl function to ensure cachePoint is added as a separate content block in the content array, rather than as a property of text objects.

- refactor: move addBedrockCacheControl over to packages/api

- ci: add tests for addBedrockCacheControl until full coverage reached

* ci: add test similar to example from the langchain PR

* refactor: move addBedrockCacheControl logic and tests to agents repository

* chore: remove extraneous comment

* chore: update @librechat/agents dependency to version 3.0.12

* chore: update @librechat/agents dependency to version 3.0.13

* chore: update @librechat/agents dependency to version 3.0.14

* chore: update @librechat/agents to v3.0.15

* chore: update default value for prompt cache setting to true

* refactor: set default promptCache to true for claude and nova models

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-11-13 10:21:50 -05:00
_juliettech
bc561840bb 🌉 feat: Integrate Helicone AI Gateway Provider (#10287)
* feat: integrate Helicone AI gateway provider

- Add Helicone provider support with automatic model fetching
- Implement custom API logic for Helicone model registry endpoint
- Enable access to 75+ models from multiple AI providers through Helicone gateway
- Add Helicone to supported providers list in README
- Include Helicone configuration in example YAML

* docs: add Helicone to supported providers list

* fix comments

* fixed backgroundless helicone icon asset

* removed unecessesary changes

* replace svg helicone image instead of png
2025-11-13 08:45:32 -05:00
Danny Avila
6e19026c48 🔍 feat: DEBUG_MESSAGE_LENGTH Environment Variable (pt. 2) (#10479) 2025-11-13 08:38:38 -05:00
Danny Avila
524fc5bae4 🛡️ feat: Add Model Refusal Error Handling (Anthropic) (#10478)
* feat: Add error handling for model refusal and update translations

* refactor: error handling in AgentClient to improve logging and cleanup process

* refactor: Update error message for response refusal to improve clarity
2025-11-13 08:34:55 -05:00
Danny Avila
3f62ce054f 🔢 fix: Unescape LaTeX Numbers in Artifact Content Edit (#10476) 2025-11-13 08:19:19 -05:00
Danny Avila
b8b1217c34 feat: Artifact Management Enhancements, Version Control, and UI Refinements (#10318)
*  feat: Enhance Artifact Management with Version Control and UI Improvements

 feat: Improve mobile layout and responsiveness in Artifacts component

 feat: Refactor imports and remove unnecessary props in Artifact components

 feat: Enhance Artifacts and SidePanel components with improved mobile responsiveness and layout transitions

feat: Enhance artifact panel animations and improve UI responsiveness

- Updated Thinking component button styles for smoother transitions.
- Implemented dynamic rendering for artifacts panel with animation effects.
- Refactored localization keys for consistency across multiple languages.
- Added new CSS animations for iOS-inspired smooth transitions.
- Improved Tailwind CSS configuration to support enhanced animation effects.

 feat: Add fullWidth and icon support to Radio component for enhanced flexibility

refactor: Remove unused PreviewProps import in ArtifactPreview component

refactor: Improve button class handling and blur effect constants in Artifact components

 feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI

chore: Bump @librechat/client version to 0.3.2

refactor: Update button styles and transition durations for improved UI responsiveness

refactor: revert back localization key

refactor: remove unused scaling and animation properties for cleaner CSS

refactor: remove unused animation properties for cleaner configuration

*  refactor: Simplify className usage in ArtifactTabs, ArtifactsHeader, and SidePanelGroup components

* refactor: Remove cycleArtifact function from useArtifacts hook

*  feat: Implement Chromium resize lag fix with performance optimizations and new ArtifactsPanel component

*  feat: Update Badge component for responsive design and improve tap scaling behavior

* chore: Update react-resizable-panels dependency to version 3.0.6

*  feat: Refactor Artifacts components for improved structure and performance; remove unused files and optimize styles

*  style: Update text color for improved visibility in Artifacts component

*  style: Remove text color class for improved Spinner styling in Artifacts component

* refactor: Split EditorContext into MutationContext and CodeContext to optimize re-renders; update related components to use new hooks

* refactor: Optimize debounced mutation handling in CodeEditor component using refs to maintain current values and reduce re-renders

* fix: Correct endpoint for message artifacts by changing URL segment from 'artifacts' to 'artifact'

* feat: Enhance useEditArtifact mutation with optimistic updates and rollback on error; improve type safety with context management

* fix: proper switch to preview as soon as artifact becomes enclosed

* refactor: Remove optimistic updates from useEditArtifact mutation to prevent errors; simplify onMutate logic

* test: Add comprehensive unit tests for useArtifacts hook to validate artifact handling, tab switching, and state management

* test: Enhance unit tests for useArtifacts hook to cover new conversation transitions and null message handling

---------

Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
2025-11-12 13:32:47 -05:00
Danny Avila
4186db3ce2 📦 chore: Bump @modelcontextprotocol/sdk to v1.21.0 (#10469) 2025-11-12 09:10:21 -05:00
github-actions[bot]
7670cd9ee5 🌍 i18n: Update translation.json with latest translations (#10458)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-12 08:47:15 -05:00
Danny Avila
dd35f42073 🔒 feat: Idempotency Check for OAuth Flow Completion (#10468)
* 🔒 feat: Implement idempotency check for OAuth flow completion

- Added a check to prevent duplicate token exchanges if the OAuth flow has already been completed.
- Updated the OAuth callback route to redirect appropriately when a completed flow is detected.
- Refactored token storage logic to use original flow state credentials instead of updated ones.
- Enhanced tests to cover the new idempotency behavior and ensure correct handling of OAuth flow states.

* chore: add back scope for logging

* refactor: Add isFlowStale method to FlowStateManager for stale flow detection

- Implemented a new method to check if a flow is stale based on its age and status.
- Updated MCPConnectionFactory to utilize the isFlowStale method for cleaning up stale OAuth flows.
- Enhanced logging to provide more informative messages regarding flow status and age during cleanup.

* test: Add unit tests for isFlowStale method in FlowStateManager

- Implemented comprehensive tests for the isFlowStale method to verify its behavior across various flow statuses (PENDING, COMPLETED, FAILED) and age thresholds.
- Ensured correct handling of edge cases, including flows with missing timestamps and custom stale thresholds.
- Enhanced test coverage to validate the logic for determining flow staleness based on createdAt, completedAt, and failedAt timestamps.
2025-11-12 08:44:45 -05:00
Danny Avila
a49c509ebc 📐 chore: Update extractDefaultParams to return undefined for invalid input 2025-11-11 15:36:07 -05:00
Danny Avila
970a7510bb 🛝 feat: Default Params via Custom Params (#10457) 2025-11-11 15:31:52 -05:00
Danny Avila
2b0fe036a8 🔍 feat: Anthropic/Google Web Search Support via addParams / dropParams (#10456)
* feat: add support for known/add/drop parameters in Anthropic and Google LLM configurations

* ci: add tests for web search support for Anthropic and Google configurations with addParams and dropParams handling
2025-11-11 14:39:12 -05:00
github-actions[bot]
4685a063f5 🌍 i18n: Update translation.json with latest translations (#10448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-11 14:15:41 -05:00
Danny Avila
b6ba2711f9 Merge commit from fork
- Implemented validation for OpenAPI specifications to ensure the server URL matches the client-provided domain, preventing SSRF attacks.
- Added domain extraction and validation functions to improve security checks.
- Updated relevant services and routes to utilize the new validation logic, ensuring robust handling of client-provided domains against the OpenAPI spec.
- Introduced comprehensive tests to validate the new security features and ensure correct behavior across various scenarios.
2025-11-11 14:14:55 -05:00
Danny Avila
4e4c8d0c0e 📜 feat: Configurable Debug Message Length for Logs (#10447)
- Added DEBUG_MESSAGE_LENGTH constant to allow dynamic adjustment of debug message length based on environment variable.
- Updated logging format to utilize the new constant for truncating debug messages, enhancing flexibility in log output.
2025-11-10 21:40:37 -05:00
Danny Avila
937563f645 🖼️ feat: File Size and MIME Type Filtering at Agent level (#10446)
* refactor: add image file size validation as part of payload build

* feat: implement file size and MIME type filtering in endpoint configuration

* chore: import order
2025-11-10 21:36:48 -05:00
Sean McGrath
b443254151 🔐 fix: persist new MCP oauth tokens properly (#10439)
* fix: re-fetch OAuth flow state after completeOAuthFlow

* test: add tests for MCP OAuth flow state bugs
2025-11-10 19:51:20 -05:00
Danny Avila
2524d33362 📂 refactor: Cleanup File Filtering Logic, Improve Validation (#10414)
* feat: add filterFilesByEndpointConfig to filter disabled file processing by provider

* chore: explicit define of endpointFileConfig for better debugging

* refactor: move `normalizeEndpointName` to data-provider as used app-wide

* chore: remove overrideEndpoint from useFileHandling

* refactor: improve endpoint file config selection

* refactor: update filterFilesByEndpointConfig to accept structured parameters and improve endpoint file config handling

* refactor: replace defaultFileConfig with getEndpointFileConfig for improved file configuration handling across components

* test: add comprehensive unit tests for getEndpointFileConfig to validate endpoint configuration handling

* refactor: streamline agent endpoint assignment and improve file filtering logic

* feat: add error handling for disabled file uploads in endpoint configuration

* refactor: update encodeAndFormat functions to accept structured parameters for provider and endpoint

* refactor: streamline requestFiles handling in initializeAgent function

* fix: getEndpointFileConfig partial config merging scenarios

* refactor: enhance mergeWithDefault function to support document-supported providers with comprehensive MIME types

* refactor: user-configured default file config in getEndpointFileConfig

* fix: prevent file handling when endpoint is disabled and file is dragged to chat

* refactor: move `getEndpointField` to `data-provider` and update usage across components and hooks

* fix: prioritize endpointType based on agent.endpoint in file filtering logic

* fix: prioritize agent.endpoint in file filtering logic and remove unnecessary endpointType defaulting
2025-11-10 19:05:30 -05:00
Danny Avila
06c060b983 🧰 fix: Unprocessed Tool Calls Edge Case (#10440)
* chore: temp. remove @librechat/agents

* 🔧 chore: update @langchain/core to version 0.3.79

* chore: update dependencies for @langchain/core and add back latest @librechat/agents

* chore: update @librechat/agents to version 3.0.11

* fix: enhance error handling for uncaught exceptions due to abort errors

* fix: standardize warning message for uncatchable abort errors

* fix: improve tool call handling in ModelEndHandler for unprocessed edge case

* fix: prevent content type mismatch in message updates and preserve args in final updates

* chore: add debug logging for client disposal in disposeClient function
2025-11-10 17:12:06 -05:00
350 changed files with 24916 additions and 5338 deletions

View File

@@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
# Cache connection status checks for this many milliseconds to avoid expensive verification
# MCP_CONNECTION_CHECK_TTL=60000
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false

View File

@@ -61,30 +61,23 @@ jobs:
npm run build:data-schemas
npm run build:api
- name: Run cache integration tests
- name: Run all cache integration tests (Single Redis Node)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
USE_REDIS_CLUSTER: false
REDIS_URI: redis://127.0.0.1:6379
REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration:core
run: npm run test:cache-integration
- name: Run cluster integration tests
- name: Run all cache integration tests (Redis Cluster)
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:cluster
- name: Run mcp integration tests
working-directory: packages/api
env:
NODE_ENV: test
USE_REDIS: true
REDIS_URI: redis://127.0.0.1:6379
run: npm run test:cache-integration:mcp
USE_REDIS_CLUSTER: true
REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
run: npm run test:cache-integration
- name: Stop Redis Cluster
if: always()

View File

@@ -0,0 +1,66 @@
name: Docker Dev Staging Images Build
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: lc-dev-staging-api
- target: node
file: Dockerfile
image_name: lc-dev-staging
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Prepare the environment
- name: Prepare environment
run: |
cp .env.example .env
# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}

View File

@@ -35,8 +35,6 @@ jobs:
# Run ESLint on changed files within the api/ and client/ directories.
- name: Run ESLint on changed files
env:
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
run: |
# Extract the base commit SHA from the pull_request event payload.
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
@@ -52,22 +50,10 @@ jobs:
# Ensure there are files to lint before running ESLint
if [[ -z "$CHANGED_FILES" ]]; then
echo "No matching files changed. Skipping ESLint."
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
exit 0
fi
# Set variable to allow SARIF upload
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
# Run ESLint
npx eslint --no-error-on-unmatched-pattern \
--config eslint.config.mjs \
--format @microsoft/eslint-formatter-sarif \
--output-file eslint-results.sarif $CHANGED_FILES || true
- name: Upload analysis results to GitHub
if: env.UPLOAD_SARIF == 'true'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: eslint-results.sarif
wait-for-processing: true
$CHANGED_FILES

33
.gitignore vendored
View File

@@ -67,7 +67,7 @@ bower_components/
.flooignore
#config file
librechat.yaml
#librechat.yaml
librechat.yml
# Environment
@@ -138,3 +138,34 @@ helm/**/.values.yaml
/.tabnine/
/.codeium
*.local.md
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
# Claude Flow generated files
.claude/settings.local.json
.mcp.json
claude-flow.config.json
.swarm/
.hive-mind/
.claude-flow/
memory/
coordination/
memory/claude-flow-data.json
memory/sessions/*
!memory/sessions/README.md
memory/agents/*
!memory/agents/README.md
coordination/memory_bank/*
coordination/subtasks/*
coordination/orchestration/*
*.db
*.db-journal
*.db-wal
*.sqlite
*.sqlite-journal
*.sqlite-wal
claude-flow
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt

View File

@@ -1,4 +1,4 @@
# v0.8.1-rc1
# v0.8.1-rc2
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.1-rc1
# v0.8.1-rc2
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -56,7 +56,7 @@
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
- Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
- OpenRouter, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
- Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran

View File

@@ -305,11 +305,9 @@ class AnthropicClient extends BaseClient {
}
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.anthropic,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: EModelEndpoint.anthropic,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -2,6 +2,7 @@ const crypto = require('crypto');
const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const {
countTokens,
getBalanceConfig,
extractFileContext,
encodeAndFormatAudios,
@@ -23,7 +24,6 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts');
const countTokens = require('~/server/utils/countTokens');
const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream');
@@ -81,6 +81,7 @@ class BaseClient {
throw new Error("Method 'getCompletion' must be implemented.");
}
/** @type {sendCompletion} */
async sendCompletion() {
throw new Error("Method 'sendCompletion' must be implemented.");
}
@@ -689,8 +690,7 @@ class BaseClient {
});
}
/** @type {string|string[]|undefined} */
const completion = await this.sendCompletion(payload, opts);
const { completion, metadata } = await this.sendCompletion(payload, opts);
if (this.abortController) {
this.abortController.requestCompleted = true;
}
@@ -708,6 +708,7 @@ class BaseClient {
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
...(this.metadata ?? {}),
metadata,
};
if (typeof completion === 'string') {
@@ -1212,7 +1213,8 @@ class BaseClient {
this.options.req,
attachments,
{
provider: this.options.agent?.provider,
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
},
getStrategyFunctions,
@@ -1228,7 +1230,10 @@ class BaseClient {
const videoResult = await encodeAndFormatVideos(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);
message.videos =
@@ -1240,7 +1245,10 @@ class BaseClient {
const audioResult = await encodeAndFormatAudios(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
},
getStrategyFunctions,
);
message.audios =

View File

@@ -305,7 +305,9 @@ class GoogleClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.google,
{
endpoint: EModelEndpoint.google,
},
mode,
);
message.image_urls = image_urls.length ? image_urls : undefined;

View File

@@ -354,11 +354,9 @@ class OpenAIClient extends BaseClient {
* @returns {Promise<MongoFile[]>}
*/
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.endpoint,
);
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments, {
endpoint: this.options.endpoint,
});
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}

View File

@@ -1,3 +1,4 @@
const { getBasePath } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
/**
@@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const basePath = getBasePath();
// Correct any erroneous URLs in the responseMessage.text first
intermediateSteps.forEach((step) => {
const { observation } = step;
@@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const essentialImagePath = match[0];
const fullImagePath = `${basePath}${essentialImagePath}`;
const regex = /!\[.*?\]\((.*?)\)/g;
let matchErroneous;
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
// Replace with the full path including base path
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
}
}
});
@@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) {
return;
}
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
responseMessage.text += '\n' + observedImagePath[0];
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
if (observedImagePath) {
// Fix the image path to include base path if it doesn't already
let imageMarkdown = observedImagePath[0];
const urlMatch = imageMarkdown.match(/\(([^)]+)\)/);
if (
urlMatch &&
urlMatch[1] &&
!urlMatch[1].startsWith(`${basePath}/images/`) &&
urlMatch[1].startsWith('/images/')
) {
imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`);
}
if (!responseMessage.text.includes(imageMarkdown)) {
responseMessage.text += '\n' + imageMarkdown;
logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown);
}
}
});
}

View File

@@ -74,7 +74,7 @@ describe('addImages', () => {
it('should append correctly from a real scenario', () => {
responseMessage.text =
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?';
"Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
const originalText = responseMessage.text;
const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)';
intermediateSteps.push({ observation: imageMarkdown });
@@ -139,4 +139,108 @@ describe('addImages', () => {
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
});
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should not prepend base path when image URL already has base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/librechat/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should correct erroneous URLs with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
responseMessage.text = '![desc](sandbox:/images/test.png)';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('![desc](/librechat/images/test.png)');
});
it('should handle empty base path (root deployment)', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle missing DOMAIN_CLIENT', () => {
delete process.env.DOMAIN_CLIENT;
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle observation without image path match', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](not-an-image-path)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](not-an-image-path)');
});
it('should handle nested subdirectories in base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/apps/librechat/images/test.png)');
});
it('should handle multiple observations with mixed base path scenarios', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc1](/images/test1.png)' });
intermediateSteps.push({ observation: '![desc2](/librechat/images/test2.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc1](/librechat/images/test1.png)\n![desc2](/librechat/images/test2.png)',
);
});
it('should handle complex markdown with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const complexMarkdown = `
# Document Title
![image1](/images/image1.png)
Some text between images
![image2](/images/image2.png)
`;
intermediateSteps.push({ observation: complexMarkdown });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/librechat/images/image1.png)');
});
it('should handle URLs that are already absolute', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](https://example.com/image.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](https://example.com/image.png)');
});
it('should handle data URLs', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({
observation:
'![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
});
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
);
});
});
});

View File

@@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
content: [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'I\'ll search for that information.',
[ContentTypes.TEXT]: "I'll search for that information.",
tool_call_ids: ['search_1'],
},
{
@@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
},
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
tool_call_ids: ['convert_1'],
},
{
@@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
output: '23.89°C',
},
},
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
],
},
];
@@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
expect(result[4]).toBeInstanceOf(AIMessage);
// Check first AIMessage
expect(result[0].content).toBe('I\'ll search for that information.');
expect(result[0].content).toBe("I'll search for that information.");
expect(result[0].tool_calls).toHaveLength(1);
expect(result[0].tool_calls[0]).toEqual({
id: 'search_1',
@@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
);
// Check second AIMessage
expect(result[2].content).toBe('Now, I\'ll convert the temperature.');
expect(result[2].content).toBe("Now, I'll convert the temperature.");
expect(result[2].tool_calls).toHaveLength(1);
expect(result[2].tool_calls[0]).toEqual({
id: 'convert_1',
@@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
// Check final AIMessage
expect(result[4].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
]);
});
@@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
role: 'assistant',
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
},
{ role: 'user', content: 'What\'s the weather?' },
{ role: 'user', content: "What's the weather?" },
{
role: 'assistant',
content: [
@@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
{
role: 'assistant',
content: [
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
],
},
];
@@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
]);
expect(result[2].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
]);
expect(result[3].content).toBe('Let me check that for you.');
expect(result[4].content).toBe('Sunny, 75°F');
expect(result[5].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT },
{ [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
]);
// Check that there are no consecutive AIMessages

View File

@@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
});
TestClient.sendCompletion = jest.fn(async () => {
return 'Mock response text';
return {
completion: 'Mock response text',
metadata: undefined,
};
});
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {

View File

@@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { FileContext, ContentTypes } = require('librechat-data-provider');
const { getBasePath } = require('@librechat/api');
const paths = require('~/config/paths');
const displayMessage =
@@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
this.description_for_model = `// Generate images and visuals using text.
// Guidelines:
// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
// - ALWAYS include the markdown url in your final response to show the user: ![caption](/images/id.png)
// - ALWAYS include the markdown url in your final response to show the user: ![caption](${getBasePath()}/images/id.png)
// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
// - Here's an example for generating a realistic portrait photo of a man:

View File

@@ -1,4 +1,5 @@
const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { tool } = require('@langchain/core/tools');
const { getApiKey } = require('./credentials');
@@ -19,13 +20,19 @@ function createTavilySearchTool(fields = {}) {
...kwargs,
};
const response = await fetch('https://api.tavily.com/search', {
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
};
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);
const json = await response.json();
if (!response.ok) {

View File

@@ -1,4 +1,5 @@
const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
@@ -102,13 +103,19 @@ class TavilySearchResults extends Tool {
...this.kwargs,
};
const response = await fetch('https://api.tavily.com/search', {
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
};
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);
const json = await response.json();
if (!response.ok) {

View File

@@ -1,6 +1,7 @@
const { fetch, ProxyAgent } = require('undici');
const TavilySearchResults = require('../TavilySearchResults');
jest.mock('node-fetch');
jest.mock('undici');
jest.mock('@langchain/core/utils/env');
describe('TavilySearchResults', () => {
@@ -13,6 +14,7 @@ describe('TavilySearchResults', () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
process.env = {
...originalEnv,
TAVILY_API_KEY: mockApiKey,
@@ -20,7 +22,6 @@ describe('TavilySearchResults', () => {
});
afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv;
});
@@ -35,4 +36,49 @@ describe('TavilySearchResults', () => {
});
expect(instance.apiKey).toBe(mockApiKey);
});
describe('proxy support', () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ results: [] }),
};
beforeEach(() => {
fetch.mockResolvedValue(mockResponse);
});
it('should use ProxyAgent when PROXY env var is set', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.PROXY = proxyUrl;
const mockProxyAgent = { type: 'proxy-agent' };
ProxyAgent.mockImplementation(() => mockProxyAgent);
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.objectContaining({
dispatcher: mockProxyAgent,
}),
);
});
it('should not use ProxyAgent when PROXY env var is not set', async () => {
delete process.env.PROXY;
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.not.objectContaining({
dispatcher: expect.anything(),
}),
);
});
});
});

View File

@@ -78,11 +78,11 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
return tool(
async ({ query }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
return ['No files to search. Instruct the user to add files for the search.', undefined];
}
const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
return ['There was an error authenticating the file search request.', undefined];
}
/**
@@ -122,7 +122,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
const validResults = results.filter((result) => result !== null);
if (validResults.length === 0) {
return 'No results found or errors occurred while searching the files.';
return ['No results found or errors occurred while searching the files.', undefined];
}
const formattedResults = validResults

View File

@@ -5,6 +5,7 @@ const traverse = require('traverse');
const SPLAT_SYMBOL = Symbol.for('splat');
const MESSAGE_SYMBOL = Symbol.for('message');
const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255;
const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150;
const sensitiveKeys = [
/^(sk-)[^\s]+/, // OpenAI API key pattern
@@ -118,7 +119,7 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met
return `${timestamp} ${level}: ${JSON.stringify(message)}`;
}
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), 150)}`;
let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`;
try {
if (level !== 'debug') {
return msg;

View File

@@ -12,8 +12,8 @@ const {
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry } = require('~/db/models');
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
/**
* Create an agent with the provided data.
@@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
return agent;
};
/**
* Deletes all agents created by a specific user.
* @param {string} userId - The ID of the user whose agents should be deleted.
* @returns {Promise<void>} A promise that resolves when all user agents have been deleted.
*/
const deleteUserAgents = async (userId) => {
try {
const userAgents = await getAgents({ author: userId });
if (userAgents.length === 0) {
return;
}
const agentIds = userAgents.map((agent) => agent.id);
const agentObjectIds = userAgents.map((agent) => agent._id);
for (const agentId of agentIds) {
await removeAgentFromAllProjects(agentId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.AGENT,
resourceId: { $in: agentObjectIds },
});
await Agent.deleteMany({ author: userId });
} catch (error) {
logger.error('[deleteUserAgents] General error:', error);
}
};
/**
* Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents.
@@ -856,6 +887,7 @@ module.exports = {
createAgent,
updateAgent,
deleteAgent,
deleteUserAgents,
getListAgents,
revertAgentVersion,
updateAgentProjects,

View File

@@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
*
* @async
* @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete.
* @returns {Promise<Object>} The metadata with count of deleted messages.
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages.
*/
async function deleteMessages(filter) {

View File

@@ -1,4 +1,5 @@
const { ObjectId } = require('mongodb');
const { escapeRegExp } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Constants,
@@ -13,8 +14,7 @@ const {
getProjectByName,
} = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt } = require('~/db/models');
const { escapeRegExp } = require('~/server/utils');
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
/**
* Create a pipeline for the aggregation to get prompt groups
@@ -591,6 +591,36 @@ module.exports = {
return { prompt: 'Prompt deleted successfully' };
}
},
/**
* Delete all prompts and prompt groups created by a specific user.
* @param {ServerRequest} req - The server request object.
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
*/
deleteUserPrompts: async (req, userId) => {
try {
const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
if (promptGroups.length === 0) {
return;
}
const groupIds = promptGroups.map((group) => group._id);
for (const groupId of groupIds) {
await removeGroupFromAllProjects(groupId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.PROMPTGROUP,
resourceId: { $in: groupIds },
});
await PromptGroup.deleteMany({ author: new ObjectId(userId) });
await Prompt.deleteMany({ author: new ObjectId(userId) });
} catch (error) {
logger.error('[deleteUserPrompts] General error:', error);
}
},
/**
* Update prompt group
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group

View File

@@ -136,10 +136,12 @@ const tokenValues = Object.assign(
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-opus-4': { prompt: 15, completion: 75 },
'claude-opus-4-5': { prompt: 5, completion: 25 },
'claude-sonnet-4': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-text': { prompt: 1.5, completion: 2.0 },
'deepseek-chat': { prompt: 0.28, completion: 0.42 },
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
@@ -156,6 +158,7 @@ const tokenValues = Object.assign(
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-3': { prompt: 2, completion: 12 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-beta': { prompt: 5.0, completion: 15.0 },
@@ -171,6 +174,9 @@ const tokenValues = Object.assign(
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
'grok-4': { prompt: 3.0, completion: 15.0 },
'grok-4-fast': { prompt: 0.2, completion: 0.5 },
'grok-4-1-fast': { prompt: 0.2, completion: 0.5 }, // covers reasoning & non-reasoning variants
'grok-code-fast': { prompt: 0.2, completion: 1.5 },
codestral: { prompt: 0.3, completion: 0.9 },
'ministral-3b': { prompt: 0.04, completion: 0.04 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
@@ -237,8 +243,14 @@ const cacheTokenValues = {
'claude-3.5-haiku': { write: 1, read: 0.08 },
'claude-3-5-haiku': { write: 1, read: 0.08 },
'claude-3-haiku': { write: 0.3, read: 0.03 },
'claude-haiku-4-5': { write: 1.25, read: 0.1 },
'claude-sonnet-4': { write: 3.75, read: 0.3 },
'claude-opus-4': { write: 18.75, read: 1.5 },
'claude-opus-4-5': { write: 6.25, read: 0.5 },
// DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
deepseek: { write: 0.28, read: 0.028 },
'deepseek-chat': { write: 0.28, read: 0.028 },
'deepseek-reasoner': { write: 0.28, read: 0.028 },
};
/**

View File

@@ -766,6 +766,78 @@ describe('Deepseek Model Tests', () => {
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
expect(result).toBe(true);
});
it('should return correct pricing for deepseek-chat', () => {
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'prompt' })).toBe(
tokenValues['deepseek-chat'].prompt,
);
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
tokenValues['deepseek-chat'].completion,
);
expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
});
it('should return correct pricing for deepseek-reasoner', () => {
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'prompt' })).toBe(
tokenValues['deepseek-reasoner'].prompt,
);
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
tokenValues['deepseek-reasoner'].completion,
);
expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
});
it('should handle DeepSeek model name variations with provider prefixes', () => {
const modelVariations = [
'deepseek/deepseek-chat',
'openrouter/deepseek-chat',
'deepseek/deepseek-reasoner',
];
modelVariations.forEach((model) => {
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
expect(promptMultiplier).toBe(0.28);
expect(completionMultiplier).toBe(0.42);
});
});
it('should return correct cache multipliers for DeepSeek models', () => {
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'write' })).toBe(
cacheTokenValues['deepseek-chat'].write,
);
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'read' })).toBe(
cacheTokenValues['deepseek-chat'].read,
);
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'write' })).toBe(
cacheTokenValues['deepseek-reasoner'].write,
);
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'read' })).toBe(
cacheTokenValues['deepseek-reasoner'].read,
);
});
it('should return correct cache pricing values for DeepSeek models', () => {
expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
expect(cacheTokenValues['deepseek'].write).toBe(0.28);
expect(cacheTokenValues['deepseek'].read).toBe(0.028);
});
it('should handle DeepSeek cache multipliers with model variations', () => {
const modelVariations = ['deepseek/deepseek-chat', 'openrouter/deepseek-reasoner'];
modelVariations.forEach((model) => {
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
expect(writeMultiplier).toBe(0.28);
expect(readMultiplier).toBe(0.028);
});
});
});
describe('Qwen3 Model Tests', () => {
@@ -1040,6 +1112,7 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-3',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
@@ -1083,6 +1156,7 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-3': 'gemini-3',
'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
@@ -1203,6 +1277,39 @@ describe('Grok Model Tests - Pricing', () => {
);
});
test('should return correct prompt and completion rates for Grok 4 Fast model', () => {
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-4-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 4.1 Fast models', () => {
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok Code Fast model', () => {
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'prompt' })).toBe(
tokenValues['grok-code-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'completion' })).toBe(
tokenValues['grok-code-fast'].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,
@@ -1238,6 +1345,39 @@ describe('Grok Model Tests - Pricing', () => {
tokenValues['grok-4'].completion,
);
});
test('should return correct prompt and completion rates for Grok 4 Fast model with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-4-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 4.1 Fast models with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
expect(getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(
getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'completion' }),
).toBe(tokenValues['grok-4-1-fast'].completion);
});
test('should return correct prompt and completion rates for Grok Code Fast model with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'prompt' })).toBe(
tokenValues['grok-code-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'completion' })).toBe(
tokenValues['grok-code-fast'].completion,
);
});
});
});
@@ -1370,6 +1510,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct prompt and completion rates for Claude Opus 4.5', () => {
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -1392,6 +1541,28 @@ describe('Claude Model Tests', () => {
});
});
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-opus-4-5');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',
@@ -1438,6 +1609,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct cache rates for Claude Opus 4.5', () => {
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe(
cacheTokenValues['claude-opus-4-5'].write,
);
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe(
cacheTokenValues['claude-opus-4-5'].read,
);
});
it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.1-rc1",
"version": "v0.8.1-rc2",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -43,15 +43,15 @@
"@google/generative-ai": "^0.24.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.72",
"@langchain/core": "^0.3.79",
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^3.0.5",
"@librechat/agents": "^3.0.36",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "^1.21.0",
"@node-saml/passport-saml": "^5.1.0",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.12.1",
@@ -76,7 +76,7 @@
"handlebars": "^4.7.7",
"https-proxy-agent": "^7.0.6",
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.2.0",
"keyv": "^5.3.2",
@@ -92,7 +92,7 @@
"multer": "^2.0.2",
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.9",
"nodemailer": "^7.0.11",
"ollama": "^0.5.0",
"openai": "5.8.2",
"openid-client": "^6.5.0",
@@ -117,7 +117,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.7.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^10.1.4",
"nodemon": "^3.0.3",
"supertest": "^7.1.0"

View File

@@ -350,6 +350,9 @@ function disposeClient(client) {
if (client.agentConfigs) {
client.agentConfigs = null;
}
if (client.agentIdMap) {
client.agentIdMap = null;
}
if (client.artifactPromises) {
client.artifactPromises = null;
}
@@ -376,6 +379,8 @@ function disposeClient(client) {
client.options = null;
} catch {
// Ignore errors during disposal
} finally {
logger.debug('[disposeClient] Client disposed');
}
}

View File

@@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
if (error || !user) {
return res.status(401).redirect('/login');
}
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
user.federatedTokens = {
access_token: tokenset.access_token,
id_token: tokenset.id_token,
refresh_token: refreshToken,
expires_at: claims.exp,
};
return res.status(200).send({ token, user });
} catch (error) {
logger.error('[refreshController] OpenID token refresh error', error);

View File

@@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
const {
MCPOAuthHandler,
MCPTokenStorage,
mcpServersRegistry,
normalizeHttpError,
extractWebSearchEnvVars,
} = require('@librechat/api');
const {
getFiles,
findToken,
updateUser,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
deleteAllSharedLinks,
deleteAllUserSessions,
deleteAllSharedLinks,
deleteUserById,
deleteMessages,
deletePresets,
deleteConvos,
deleteFiles,
updateUser,
findToken,
getFiles,
} = require('~/models');
const {
ConversationTag,
Transaction,
MemoryEntry,
Assistant,
AclEntry,
Balance,
Action,
Group,
Token,
User,
} = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User, Token } = require('~/db/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => {
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets
/* TODO: Delete Assistant Threads */
try {
await deleteConvos(user.id); // delete user convos
} catch (error) {
@@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
await deleteUserFiles(req); // delete user files
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls
/* TODO: queue job for cleaning actions and assistants of non-existant users */
await deleteUserAgents(user.id); // delete user agents
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
await deleteUserPrompts(req, user.id); // delete user prompts
await Action.deleteMany({ user: user.id }); // delete user actions
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
await Group.updateMany(
// remove user from all groups
{ memberIds: user.id },
{ $pull: { memberIds: user.id } },
);
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {

View File

@@ -1,7 +1,7 @@
const { nanoid } = require('nanoid');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Tools, StepTypes, FileContext } = require('librechat-data-provider');
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
const {
EnvVar,
Providers,
@@ -27,31 +27,64 @@ class ModelEndHandler {
this.collectedUsage = collectedUsage;
}
finalize(errorMessage) {
if (!errorMessage) {
return;
}
throw new Error(errorMessage);
}
/**
* @param {string} event
* @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata
* @param {StandardGraph} graph
* @returns
* @returns {Promise<void>}
*/
handle(event, data, metadata, graph) {
async handle(event, data, metadata, graph) {
if (!graph || !metadata) {
console.warn(`Graph or metadata not found in ${event} event`);
return;
}
/** @type {string | undefined} */
let errorMessage;
try {
const agentContext = graph.getAgentContext(metadata);
if (
agentContext.provider === Providers.GOOGLE ||
agentContext.clientOptions?.disableStreaming
) {
handleToolCalls(data?.output?.tool_calls, metadata, graph);
const isGoogle = agentContext.provider === Providers.GOOGLE;
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
const info = { ...data.output.additional_kwargs };
errorMessage = JSON.stringify({
type: ErrorTypes.REFUSAL,
info,
});
logger.debug(`[ModelEndHandler] Model refused to respond`, {
...info,
userId: metadata.user_id,
messageId: metadata.run_id,
conversationId: metadata.thread_id,
});
}
const toolCalls = data?.output?.tool_calls;
let hasUnprocessedToolCalls = false;
if (Array.isArray(toolCalls) && toolCalls.length > 0 && graph?.toolCallStepIds?.has) {
try {
hasUnprocessedToolCalls = toolCalls.some(
(tc) => tc?.id && !graph.toolCallStepIds.has(tc.id),
);
} catch {
hasUnprocessedToolCalls = false;
}
}
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
await handleToolCalls(toolCalls, metadata, graph);
}
const usage = data?.output?.usage_metadata;
if (!usage) {
return;
return this.finalize(errorMessage);
}
const modelName = metadata?.ls_model_name || agentContext.clientOptions?.model;
if (modelName) {
@@ -59,17 +92,16 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
const streamingDisabled = !!agentContext.clientOptions?.disableStreaming;
if (!streamingDisabled) {
return;
return this.finalize(errorMessage);
}
if (!data.output.content) {
return;
return this.finalize(errorMessage);
}
const stepKey = graph.getStepKey(metadata);
const message_id = getMessageId(stepKey, graph) ?? '';
if (message_id) {
graph.dispatchRunStep(stepKey, {
await graph.dispatchRunStep(stepKey, {
type: StepTypes.MESSAGE_CREATION,
message_creation: {
message_id,
@@ -79,7 +111,7 @@ class ModelEndHandler {
const stepId = graph.getStepIdByKey(stepKey);
const content = data.output.content;
if (typeof content === 'string') {
graph.dispatchMessageDelta(stepId, {
await graph.dispatchMessageDelta(stepId, {
content: [
{
type: 'text',
@@ -88,12 +120,13 @@ class ModelEndHandler {
],
});
} else if (content.every((c) => c.type?.startsWith('text'))) {
graph.dispatchMessageDelta(stepId, {
await graph.dispatchMessageDelta(stepId, {
content,
});
}
} catch (error) {
logger.error('Error handling model end event:', error);
return this.finalize(errorMessage);
}
}
}
@@ -129,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
}
const handlers = {
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
[GraphEvents.ON_RUN_STEP]: {
/**

View File

@@ -9,16 +9,19 @@ const {
logAxiosError,
sanitizeTitle,
resolveHeaders,
createSafeUser,
getBalanceConfig,
memoryInstructions,
getTransactionsConfig,
createMemoryProcessor,
filterMalformedContentParts,
} = require('@librechat/api');
const {
Callback,
Providers,
TitleMethod,
formatMessage,
labelContentByAgent,
formatAgentMessages,
getTokenCountForMessage,
createMetadataAggregator,
@@ -91,6 +94,61 @@ function logToolError(graph, error, toolId) {
});
}
/**
* Applies agent labeling to conversation history when multi-agent patterns are detected.
* Labels content parts by their originating agent to prevent identity confusion.
*
* @param {TMessage[]} orderedMessages - The ordered conversation messages
* @param {Agent} primaryAgent - The primary agent configuration
* @param {Map<string, Agent>} agentConfigs - Map of additional agent configurations
* @returns {TMessage[]} Messages with agent labels applied where appropriate
*/
function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) {
const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
if (!shouldLabelByAgent) {
return orderedMessages;
}
const processedMessages = [];
for (let i = 0; i < orderedMessages.length; i++) {
const message = orderedMessages[i];
/** @type {Record<string, string>} */
const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
if (agentConfigs) {
for (const [agentId, agentConfig] of agentConfigs.entries()) {
agentNames[agentId] = agentConfig.name || agentConfig.id;
}
}
if (
!message.isCreatedByUser &&
message.metadata?.agentIdMap &&
Array.isArray(message.content)
) {
try {
const labeledContent = labelContentByAgent(
message.content,
message.metadata.agentIdMap,
agentNames,
);
processedMessages.push({ ...message, content: labeledContent });
} catch (error) {
logger.error('[AgentClient] Error applying agent labels to message:', error);
processedMessages.push(message);
}
} else {
processedMessages.push(message);
}
}
return processedMessages;
}
class AgentClient extends BaseClient {
constructor(options = {}) {
super(null, options);
@@ -140,6 +198,8 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap = {};
/** @type {(messages: BaseMessage[]) => Promise<void>} */
this.processMemory;
/** @type {Record<number, string> | null} */
this.agentIdMap = null;
}
/**
@@ -210,7 +270,10 @@ class AgentClient extends BaseClient {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
this.options.agent.provider,
{
provider: this.options.agent.provider,
endpoint: this.options.endpoint,
},
VisionModes.agents,
);
message.image_urls = image_urls.length ? image_urls : undefined;
@@ -229,6 +292,12 @@ class AgentClient extends BaseClient {
summary: this.shouldSummarize,
});
orderedMessages = applyAgentLabelsToHistory(
orderedMessages,
this.options.agent,
this.agentConfigs,
);
let payload;
/** @type {number | undefined} */
let promptTokens;
@@ -341,7 +410,7 @@ class AgentClient extends BaseClient {
if (mcpServers.length > 0) {
try {
const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers);
const mcpInstructions = await getMCPManager().formatInstructionsForContext(mcpServers);
if (mcpInstructions) {
systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n');
logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers);
@@ -608,7 +677,11 @@ class AgentClient extends BaseClient {
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
return this.contentParts;
const completion = filterMalformedContentParts(this.contentParts);
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
return { completion, metadata };
}
/**
@@ -761,12 +834,14 @@ class AgentClient extends BaseClient {
let run;
/** @type {Promise<(TAttachment | null)[] | undefined>} */
let memoryPromise;
const appConfig = this.options.req.config;
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
try {
if (!abortController) {
abortController = new AbortController();
}
const appConfig = this.options.req.config;
/** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
@@ -782,7 +857,7 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
user: this.options.req.user,
user: createSafeUser(this.options.req.user),
},
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
signal: abortController.signal,
@@ -858,6 +933,7 @@ class AgentClient extends BaseClient {
signal: abortController.signal,
customHandlers: this.options.eventHandlers,
requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tokenCounter: createTokenCounter(this.getEncoding()),
});
@@ -898,29 +974,23 @@ class AgentClient extends BaseClient {
}
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
/** Capture agent ID map if we have edges or multiple agents */
const shouldStoreAgentMap =
(this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
if (shouldStoreAgentMap && run?.Graph) {
const contentPartAgentMap = run.Graph.getContentPartAgentMap();
if (contentPartAgentMap && contentPartAgentMap.size > 0) {
this.agentIdMap = Object.fromEntries(contentPartAgentMap);
logger.debug('[AgentClient] Captured agent ID map:', {
totalParts: this.contentParts.length,
mappedParts: Object.keys(this.agentIdMap).length,
});
}
}
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error recording collected usage',
err,
);
} catch (error) {
logger.error('[AgentClient] Error capturing agent ID map:', error);
}
} catch (err) {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
err,
@@ -935,6 +1005,27 @@ class AgentClient extends BaseClient {
[ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`,
});
}
} finally {
try {
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
if (attachments && attachments.length > 0) {
this.artifactPromises.push(...attachments);
}
await this.recordCollectedUsage({
context: 'message',
balance: balanceConfig,
transactions: transactionsConfig,
});
} catch (err) {
logger.error(
'[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase',
err,
);
}
run = null;
config = null;
memoryPromise = null;
}
}
@@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient {
if (clientOptions?.configuration?.defaultHeaders != null) {
clientOptions.configuration.defaultHeaders = resolveHeaders({
headers: clientOptions.configuration.defaultHeaders,
user: createSafeUser(this.options.req?.user),
body: {
messageId: this.responseMessageId,
conversationId: this.conversationId,

View File

@@ -14,6 +14,14 @@ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
// Mock getMCPManager
const mockFormatInstructions = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: mockFormatInstructions,
})),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@@ -981,7 +989,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1001,7 +1009,7 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1026,7 +1034,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1047,7 +1055,7 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1060,6 +1068,9 @@ describe('AgentClient - titleConvo', () => {
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1079,7 +1090,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1097,6 +1111,9 @@ describe('AgentClient - titleConvo', () => {
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
@@ -1116,7 +1133,10 @@ describe('AgentClient - titleConvo', () => {
useResponsesApi: true,
};
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
@@ -1149,7 +1169,10 @@ describe('AgentClient - titleConvo', () => {
};
// Simulate the getOptions logic
if (/\bgpt-[5-9]\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
@@ -1168,6 +1191,200 @@ describe('AgentClient - titleConvo', () => {
});
});
describe('buildMessages with MCP server instructions', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock to default behavior
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nTest MCP instructions here',
);
const { DynamicStructuredTool } = require('@langchain/core/tools');
// Create mock MCP tools with the delimiter pattern
const mockMCPTool1 = new DynamicStructuredTool({
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Test MCP tool 1',
schema: {},
func: async () => 'result',
});
const mockMCPTool2 = new DynamicStructuredTool({
name: `tool2${Constants.mcp_delimiter}server2`,
description: 'Test MCP tool 2',
schema: {},
func: async () => 'result',
});
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Base agent instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [mockMCPTool1, mockMCPTool2],
};
mockReq = {
user: {
id: 'user-123',
},
body: {
endpoint: EModelEndpoint.openAI,
},
config: {},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
});
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nUse these tools carefully',
);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with correct server names
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2']);
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify the instructions DO contain the MCP instructions
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included
expect(client.options.agent.instructions).toContain('Base instructions');
});
it('should handle MCP instructions with ephemeral agent', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
);
// Set up ephemeral agent with MCP servers
mockReq.body.ephemeralAgent = {
mcp: ['ephemeral-server1', 'ephemeral-server2'],
};
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Test ephemeral',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Ephemeral instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with ephemeral server names
expect(mockFormatInstructions).toHaveBeenCalledWith([
'ephemeral-server1',
'ephemeral-server2',
]);
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify ephemeral MCP instructions are included
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
});
it('should handle empty MCP instructions gracefully', async () => {
// Set empty return value for this test
mockFormatInstructions.mockResolvedValue('');
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions only',
additional_instructions: null,
});
// Verify the instructions still work without MCP content
expect(client.options.agent.instructions).toBe('Base instructions only');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
it('should handle MCP instructions error gracefully', async () => {
// Set error return for this test
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
// Should not throw
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Should still have base instructions without MCP content
expect(client.options.agent.instructions).toContain('Base instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
describe('runMemory method', () => {
let client;
let mockReq;

View File

@@ -1,6 +1,10 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const {
sendEvent,
sanitizeFileForTransmit,
sanitizeMessageForTransmit,
} = require('@librechat/api');
const {
handleAbortError,
createAbortController,
@@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
// Process files if needed
// Process files if needed (sanitize to remove large text fields before transmission)
if (req.body.files && client.options?.attachments) {
userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (let attachment of client.options.attachments) {
for (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) {
userMessage.files.push({ ...attachment });
userMessage.files.push(sanitizeFileForTransmit(attachment));
}
}
delete userMessage.image_urls;
@@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse,
});
res.end();
@@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
final: true,
conversation,
title: conversation.title,
requestMessage: userMessage,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse,
error: { message: 'Request was aborted during completion' },
});

View File

@@ -11,7 +11,6 @@ const {
const {
Tools,
Constants,
SystemRoles,
FileSources,
ResourceType,
AccessRoleIds,
@@ -20,6 +19,8 @@ const {
PermissionBits,
actionDelimiter,
removeNullishValues,
CacheKeys,
Time,
} = require('librechat-data-provider');
const {
getListAgentsByAccess,
@@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const { getLogStores } = require('~/cache');
const systemTools = {
[Tools.execute_code]: true,
@@ -52,6 +54,49 @@ const systemTools = {
[Tools.web_search]: true,
};
const MAX_SEARCH_LEN = 100;
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Opportunistically refreshes S3-backed avatars for agent list responses.
* Only list responses are refreshed because they're the highest-traffic surface and
* the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
* via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most.
* @param {Array} agents - Agents being enriched with S3-backed avatars
* @param {string} userId - User identifier used for the cache refresh key
*/
const refreshListAvatars = async (agents, userId) => {
if (!agents?.length) {
return;
}
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_list`;
const alreadyChecked = await cache.get(refreshKey);
if (alreadyChecked) {
return;
}
await Promise.all(
agents.map(async (agent) => {
if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
return;
}
try {
const newPath = await refreshS3Url(agent.avatar);
if (newPath && newPath !== agent.avatar.filepath) {
agent.avatar = { ...agent.avatar, filepath: newPath };
}
} catch (err) {
logger.debug('[/Agents] Avatar refresh error for list item', err);
}
}),
);
await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
};
/**
* Creates an Agent.
* @route POST /Agents
@@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const originalUrl = agent.avatar.filepath;
agent.avatar.filepath = await refreshS3Url(agent.avatar);
if (originalUrl !== agent.avatar.filepath) {
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id });
try {
agent.avatar = {
...agent.avatar,
filepath: await refreshS3Url(agent.avatar),
};
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
}
}
@@ -209,7 +257,12 @@ const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body);
const { _id, ...updateData } = removeNullishValues(validatedData);
// Preserve explicit null for avatar to allow resetting the avatar
const { avatar: avatarField, _id, ...rest } = validatedData;
const updateData = removeNullishValues(rest);
if (avatarField === null) {
updateData.avatar = avatarField;
}
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
@@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => {
const [domain] = action.action_id.split(actionDelimiter);
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
// Sanitize sensitive metadata before persisting
const filteredMetadata = { ...(action.metadata || {}) };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
const newAction = await updateAction(
{ action_id: newActionId },
{
metadata: action.metadata,
metadata: filteredMetadata,
agent_id: newAgentId,
user: userId,
},
);
const filteredMetadata = { ...newAction.metadata };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
newAction.metadata = filteredMetadata;
newActionsList.push(newAction);
return fullActionId;
};
@@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => {
filter.is_promoted = { $ne: true };
}
// Handle search filter
// Handle search filter (escape regex and cap length)
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
const regex = new RegExp(safeSearch, 'i');
filter.$or = [{ name: regex }, { description: regex }];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
@@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => {
resourceType: ResourceType.AGENT,
requiredPermissions: requiredPermission,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: ResourceType.AGENT,
requiredPermissions: PermissionBits.VIEW,
});
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
@@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => {
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
const agents = data?.data ?? [];
if (!agents.length) {
return res.json(data);
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
data.data = agents.map((agent) => {
try {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true;
}
return agent;
});
} catch (e) {
// Silently ignore mapping errors
void e;
}
return agent;
});
// Opportunistically refresh S3 avatar URLs for list results with caching
try {
await refreshListAvatars(data.data, req.user.id);
} catch (err) {
logger.debug('[/Agents] Skipping avatar refresh for list', err);
}
return res.json(data);
} catch (error) {
@@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => {
const uploadAgentAvatarHandler = async (req, res) => {
try {
const appConfig = req.config;
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
filterFile({ req, file: req.file, image: true, isAvatar: true });
const { agent_id } = req.params;
if (!agent_id) {
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.toString();
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 = getFileStrategy(appConfig, { isAvatar: true });
const resizedBuffer = await resizeAvatar({
@@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
}
}
const promises = [];
const data = {
avatar: {
filepath: image.filepath,
@@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => {
},
};
promises.push(
await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
}),
);
const resolved = await Promise.all(promises);
res.status(201).json(resolved[0]);
const updatedAgent = await updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
res.status(201).json(updatedAgent);
} catch (error) {
const message = 'An error occurred while updating the Agent Avatar';
logger.error(message, error);
logger.error(
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
error,
);
res.status(500).json({ message });
} finally {
try {
@@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => {
return res.status(400).json({ error: 'version_index is required' });
}
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const isAuthor = existingAgent.author.toString() === req.user.id.toString();
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',
});
}
// Permissions are enforced via route middleware (ACL EDIT)
const updatedAgent = await revertAgentVersion({ id }, version_index);

View File

@@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
grantPermission: jest.fn(),
hasPublicPermission: jest.fn().mockResolvedValue(false),
checkPermission: jest.fn().mockResolvedValue(true),
}));
jest.mock('~/models', () => ({
@@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.version).toBe(agentInDb.versions.length);
});
test('should allow resetting avatar when value is explicitly null', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
avatar: {
filepath: 'https://example.com/avatar.png',
source: 's3',
},
},
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: null,
};
await updateAgentHandler(mockReq, mockRes);
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.avatar).toBeNull();
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar).toBeNull();
});
test('should ignore avatar field when value is undefined', async () => {
const originalAvatar = {
filepath: 'https://example.com/original.png',
source: 's3',
};
await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
expect(agentInDb.avatar.source).toBe(originalAvatar.source);
});
test('should not bump version when no mutable fields change', async () => {
const existingAgent = await Agent.findOne({ id: existingAgentId });
const originalVersionCount = existingAgent.versions.length;
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.versions.length).toBe(originalVersionCount);
});
test('should handle validation errors properly', async () => {
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;

View File

@@ -1,7 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const {
Time,
Constants,
@@ -33,7 +33,6 @@ const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getOpenAIClient } = require('./helpers');
/**

View File

@@ -1,7 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const {
Time,
Constants,
@@ -30,7 +30,6 @@ const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getOpenAIClient } = require('./helpers');
/**

View File

@@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => {
continue;
}
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;

417
api/server/experimental.js Normal file
View File

@@ -0,0 +1,417 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cluster = require('cluster');
const Redis = require('ioredis');
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
ErrorController,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes');
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
/** Allow PORT=0 to be used for automatic free port assignment */
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
const host = HOST || 'localhost';
const trusted_proxy = Number(TRUST_PROXY) || 1;
/** Number of worker processes to spawn (simulating multiple pods) */
const workers = Number(process.env.CLUSTER_WORKERS) || 4;
/** Helper to wrap log messages for better visibility */
const wrapLogMessage = (msg) => {
return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
};
/**
* Flushes the Redis cache on startup
* This ensures a clean state for testing multi-pod MCP connection issues
*/
const flushRedisCache = async () => {
/** Skip cache flush if Redis is not enabled */
if (!isEnabled(process.env.USE_REDIS)) {
logger.info('Redis is not enabled, skipping cache flush');
return;
}
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
};
if (process.env.REDIS_PASSWORD) {
redisConfig.password = process.env.REDIS_PASSWORD;
}
/** Handle Redis Cluster configuration */
if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
logger.info('Detected Redis Cluster configuration');
const uris = process.env.REDIS_URI?.split(',').map((uri) => {
const url = new URL(uri.trim());
return {
host: url.hostname,
port: parseInt(url.port || '6379', 10),
};
});
const redis = new Redis.Cluster(uris, {
redisOptions: {
password: process.env.REDIS_PASSWORD,
},
});
try {
logger.info('Attempting to connect to Redis Cluster...');
await redis.ping();
logger.info('Connected to Redis Cluster. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
]);
logger.info('Redis Cluster cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis Cluster cache:', err);
throw err;
} finally {
redis.disconnect();
}
return;
}
/** Handle single Redis instance */
const redis = new Redis(redisConfig);
try {
logger.info('Attempting to connect to Redis...');
await redis.ping();
logger.info('Connected to Redis. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
]);
logger.info('Redis cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis cache:', err);
throw err;
} finally {
redis.disconnect();
}
};
/**
* Master process
* Manages worker processes and handles graceful shutdowns
*/
if (cluster.isMaster) {
logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
let activeWorkers = 0;
const startTime = Date.now();
/** Flush Redis cache before starting workers */
flushRedisCache()
.then(() => {
logger.info('Cache flushed, forking workers...');
for (let i = 0; i < workers; i++) {
cluster.fork();
}
})
.catch((err) => {
logger.error('Unable to flush Redis cache, not forking workers:', err);
process.exit(1);
});
/** Track worker lifecycle */
cluster.on('online', (worker) => {
activeWorkers++;
const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
logger.info(
`Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
);
/** Notify the last worker to perform one-time initialization tasks */
if (activeWorkers === workers) {
const allWorkers = Object.values(cluster.workers);
const lastWorker = allWorkers[allWorkers.length - 1];
if (lastWorker) {
logger.info(wrapLogMessage(`All ${workers} workers are online`));
lastWorker.send({ type: 'last-worker' });
}
}
});
cluster.on('exit', (worker, code, signal) => {
activeWorkers--;
logger.error(
`Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
);
logger.info('Starting a new worker to replace it...');
cluster.fork();
});
/** Graceful shutdown on SIGTERM/SIGINT */
const shutdown = () => {
logger.info('Master received shutdown signal, terminating workers...');
for (const id in cluster.workers) {
cluster.workers[id].kill();
}
setTimeout(() => {
logger.info('Forcing shutdown after timeout');
process.exit(0);
}, 10000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} else {
/**
* Worker process
* Each worker runs a full Express server instance
*/
const app = express();
const startServer = async () => {
logger.info(`Worker ${process.pid} initializing...`);
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
/** Connect to MongoDB */
await connectDb();
logger.info(`Worker ${process.pid}: Connected to MongoDB`);
/** Background index sync (non-blocking) */
indexSync().catch((err) => {
logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
});
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
/** Seed database (idempotent) */
await seedDatabase();
/** Initialize app configuration */
const appConfig = await getAppConfig();
initializeFileStorage(appConfig);
await performStartupChecks(appConfig);
await updateInterfacePermissions(appConfig);
/** Load index.html for SPA serving */
const indexPath = path.join(appConfig.paths.dist, 'index.html');
let indexHTML = fs.readFileSync(indexPath, 'utf8');
/** Support serving in subdirectory if DOMAIN_CLIENT is set */
if (process.env.DOMAIN_CLIENT) {
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
const baseHref = clientUrl.pathname.endsWith('/')
? clientUrl.pathname
: `${clientUrl.pathname}/`;
if (baseHref !== '/') {
logger.info(`Setting base href to ${baseHref}`);
indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
}
}
/** Health check endpoint */
app.get('/health', (_req, res) => res.status(200).send('OK'));
/** Middleware */
app.use(noIndex);
app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(handleJsonParseError);
app.use(mongoSanitize());
app.use(cors());
app.use(cookieParser());
if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression());
} else {
logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
}
app.use(staticCache(appConfig.paths.dist));
app.use(staticCache(appConfig.paths.fonts));
app.use(staticCache(appConfig.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) {
logger.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
}
/** OAUTH */
app.use(passport.initialize());
passport.use(jwtLogin());
passport.use(passportLogin());
/** LDAP Auth */
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
passport.use(ldapLogin);
}
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
await configureSocialLogins(app);
}
/** Routes */
app.use('/oauth', routes.oauth);
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
app.use('/api/plugins', routes.plugins);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
/** Error handler */
app.use(ErrorController);
/** SPA fallback - serve index.html for all unmatched routes */
app.use((req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
Expires: process.env.INDEX_EXPIRES || '0',
});
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
const saneLang = lang.replace(/"/g, '&quot;');
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
res.type('html');
res.send(updatedIndexHtml);
});
/** Start listening on shared port (cluster will distribute connections) */
app.listen(port, host, async () => {
logger.info(
`Worker ${process.pid} started: Server listening at http://${
host == '0.0.0.0' ? 'localhost' : host
}:${port}`,
);
/** Initialize MCP servers and OAuth reconnection for this worker */
await initializeMCPs();
await initializeOAuthReconnectManager();
await checkMigrations();
});
/** Handle inter-process messages from master */
process.on('message', async (msg) => {
if (msg.type === 'last-worker') {
logger.info(
wrapLogMessage(
`Worker ${process.pid} is the last worker and can perform special initialization tasks`,
),
);
/** Add any one-time initialization tasks here */
/** For example: scheduled jobs, cleanup tasks, etc. */
}
});
};
startServer().catch((err) => {
logger.error(`Failed to start worker ${process.pid}:`, err);
process.exit(1);
});
/** Export app for testing purposes (only available in worker processes) */
module.exports = app;
}
/**
* Uncaught exception handler
* Filters out known non-critical errors
*/
let messageCount = 0;
process.on('uncaughtException', (err) => {
if (!err.message.includes('fetch failed')) {
logger.error('There was an uncaught error:', err);
}
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
logger.warn('There was an uncatchable abort error.');
return;
}
if (err.message.includes('GoogleGenerativeAI')) {
logger.warn(
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
);
return;
}
if (err.message.includes('fetch failed')) {
if (messageCount === 0) {
logger.warn('Meilisearch error, search will be disabled');
messageCount++;
}
return;
}
if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
logger.error(
'\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
);
return;
}
if (err.stack && err.stack.includes('@librechat/agents')) {
logger.error(
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
{
message: err.message,
stack: err.stack,
},
);
return;
}
process.exit(1);
});

View File

@@ -14,6 +14,7 @@ const {
isEnabled,
ErrorController,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
@@ -81,6 +82,7 @@ const startServer = async () => {
app.use(noIndex);
app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(handleJsonParseError);
app.use(mongoSanitize());
app.use(cors());
app.use(cookieParser());
@@ -126,7 +128,6 @@ const startServer = async () => {
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories);
app.use('/api/tokenizer', routes.tokenizer);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models);
@@ -185,8 +186,8 @@ process.on('uncaughtException', (err) => {
logger.error('There was an uncaught error:', err);
}
if (err.message.includes('abort')) {
logger.warn('There was an uncatchable AbortController error.');
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
logger.warn('There was an uncatchable abort error.');
return;
}
@@ -213,6 +214,17 @@ process.on('uncaughtException', (err) => {
return;
}
if (err.stack && err.stack.includes('@librechat/agents')) {
logger.error(
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
{
message: err.message,
stack: err.stack,
},
);
return;
}
process.exit(1);
});

View File

@@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { countTokens, isEnabled, sendEvent, sanitizeMessageForTransmit } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
@@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
final: true,
conversation,
requestMessage: userMessage,
requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: responseMessage,
};
};

View File

@@ -61,18 +61,24 @@ async function buildEndpointOption(req, res, next) {
try {
currentModelSpec.preset.spec = spec;
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
currentModelSpec.preset.iconURL = currentModelSpec.iconURL;
}
parsedBody = parseCompactConvo({
endpoint,
endpointType,
conversation: currentModelSpec.preset,
});
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
parsedBody.iconURL = currentModelSpec.iconURL;
}
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
// Non-enforced mode: if spec is selected, derive iconURL from model spec
const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
if (modelSpec?.iconURL) {
parsedBody.iconURL = modelSpec.iconURL;
}
}
try {

View File

@@ -1,7 +1,7 @@
const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api');
const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
@@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => {
return sendEvent(res, {
final: true,
requestMessage: query?.[0] ? query[0] : requestMessage,
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
responseMessage: errorMessage,
conversation: convo,
});

View File

@@ -1,11 +1,14 @@
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
}));
const { isEnabled } = require('@librechat/api');
describe('validateImageRequest middleware', () => {
let req, res, next, validateImageRequest;
const validObjectId = '65cfb246f7ecadb8b1e8036b';
@@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
process.env.OPENID_REUSE_TOKENS = 'false';
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
// Default: OpenID token reuse disabled
isEnabled.mockReturnValue(false);
@@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
});
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
test('should validate image paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should validate agent avatar paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle empty base path (root deployment)', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing DOMAIN_CLIENT', async () => {
delete process.env.DOMAIN_CLIENT;
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle nested subdirectories in base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent path traversal with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with query parameters and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with fragments and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle HTTPS URLs with base path', async () => {
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle OpenID flow with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
process.env.OPENID_REUSE_TOKENS = 'true';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,7 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBasePath } = require('@librechat/api');
const OBJECT_ID_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
@@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
return res.status(403).send('Access Denied');
}
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/;
const basePath = getBasePath();
const imagesPath = `${basePath}/images`;
const agentAvatarPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
);
if (agentAvatarPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
return next();
}
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
const pathPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');

View File

@@ -290,6 +290,7 @@ describe('MCP Routes', () => {
it('should handle OAuth callback successfully', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -382,6 +383,7 @@ describe('MCP Routes', () => {
it('should handle system-level OAuth completion', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -417,6 +419,7 @@ describe('MCP Routes', () => {
it('should handle reconnection failure after OAuth', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@@ -498,6 +501,108 @@ describe('MCP Routes', () => {
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
});
it('should use original flow state credentials when storing tokens', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
getFlowState: jest.fn(),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const clientInfo = {
client_id: 'client123',
client_secret: 'client_secret',
};
const flowState = {
serverName: 'test-server',
userId: 'test-user-id',
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
clientInfo: clientInfo,
codeVerifier: 'test-verifier',
status: 'PENDING',
};
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// First call checks idempotency (status PENDING = not completed)
// Second call retrieves flow state for processing
mockFlowManager.getFlowState
.mockResolvedValueOnce({ status: 'PENDING' })
.mockResolvedValueOnce(flowState);
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([]),
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
clearReconnection: jest.fn(),
});
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify storeTokens was called with ORIGINAL flow state credentials
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'test-user-id',
serverName: 'test-server',
tokens: mockTokens,
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
metadata: flowState.metadata,
}),
);
});
it('should prevent duplicate token exchange with idempotency check', async () => {
const mockFlowManager = {
getFlowState: jest.fn(),
};
// Flow is already completed
mockFlowManager.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
MCPOAuthHandler.getFlowState.mockResolvedValue({
status: 'COMPLETED',
serverName: 'test-server',
userId: 'test-user-id',
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
});
expect(response.status).toBe(302);
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
// Verify completeOAuthFlow was NOT called (prevented duplicate)
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
});
describe('GET /oauth/tokens/:flowId', () => {
@@ -1242,7 +1347,9 @@ describe('MCP Routes', () => {
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
completeFlow: jest.fn(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);

View File

@@ -9,6 +9,8 @@ const {
PermissionTypes,
actionDelimiter,
removeNullishValues,
validateActionDomain,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
@@ -83,6 +85,32 @@ router.post(
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const appConfig = req.config;
// SECURITY: Validate the OpenAPI spec and extract the server URL
if (metadata.raw_spec) {
const validationResult = validateAndParseOpenAPISpec(metadata.raw_spec);
if (!validationResult.status || !validationResult.serverUrl) {
return res.status(400).json({
message: validationResult.message || 'Invalid OpenAPI specification',
});
}
// SECURITY: Validate the client-provided domain matches the spec's server URL domain
// This prevents SSRF attacks where an attacker provides a whitelisted domain
// but uses a different (potentially internal) URL in the raw_spec
const domainValidation = validateActionDomain(metadata.domain, validationResult.serverUrl);
if (!domainValidation.isValid) {
logger.warn(`Domain mismatch detected: ${domainValidation.message}`, {
userId: req.user.id,
agent_id,
});
return res.status(400).json({
message:
'Domain mismatch: The domain in the OpenAPI spec does not match the provided domain',
});
}
}
const isDomainAllowed = await isActionDomainAllowed(
metadata.domain,
appConfig?.actions?.allowedDomains,

View File

@@ -146,7 +146,15 @@ router.delete(
* @param {number} req.body.version_index - Index of the version to revert to.
* @returns {Agent} 200 - success response - application/json
*/
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
router.post(
'/:id/revert',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.revertAgentVersion,
);
/**
* Returns a list of agents.

View File

@@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Fetches MCP servers from registry and adds them to the payload.
* Registry now includes all configured servers (from YAML) plus inspection data when available.
* Always fetches fresh to avoid caching incomplete initialization state.
*/
const getMCPServers = async (payload, appConfig) => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) {
const appConfig = await getAppConfig({ role: req.user?.role });
await getMCPServers(cachedStartupConfig, appConfig);
res.send(cachedStartupConfig);
return;
}
@@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
payload.minPasswordLength = minPasswordLength;
}
const getMCPServers = async () => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
await getMCPServers();
const webSearchConfig = appConfig?.webSearch;
if (
webSearchConfig != null &&
@@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
}
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
await getMCPServers(payload, appConfig);
return res.status(200).send(payload);
} catch (err) {
logger.error('Error in startup config', err);

View File

@@ -10,8 +10,8 @@ const {
ResourceType,
EModelEndpoint,
PermissionBits,
isAgentsEndpoint,
checkOpenAIStorage,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const {
filterFile,
@@ -376,11 +376,11 @@ router.post('/', async (req, res) => {
metadata.temp_file_id = metadata.file_id;
metadata.file_id = req.file_id;
if (isAgentsEndpoint(metadata.endpoint)) {
return await processAgentFileUpload({ req, res, metadata });
if (isAssistantsEndpoint(metadata.endpoint)) {
return await processFileUpload({ req, res, metadata });
}
await processFileUpload({ req, res, metadata });
return await processAgentFileUpload({ req, res, metadata });
} catch (error) {
let message = 'Error processing file';
logger.error('[/files] Error processing file:', error);

View File

@@ -3,7 +3,11 @@ const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sanitizeFilename } = require('@librechat/api');
const { fileConfig: defaultFileConfig, mergeFileConfig } = require('librechat-data-provider');
const {
mergeFileConfig,
getEndpointFileConfig,
fileConfig: defaultFileConfig,
} = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const storage = multer.diskStorage({
@@ -53,12 +57,14 @@ const createFileFilter = (customFileConfig) => {
}
const endpoint = req.body.endpoint;
const supportedTypes =
customFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes ??
customFileConfig?.endpoints?.default.supportedMimeTypes ??
defaultFileConfig?.endpoints?.[endpoint]?.supportedMimeTypes;
const endpointType = req.body.endpointType;
const endpointFileConfig = getEndpointFileConfig({
fileConfig: customFileConfig,
endpoint,
endpointType,
});
if (!defaultFileConfig.checkType(file.mimetype, supportedTypes)) {
if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) {
return cb(new Error('Unsupported file type: ' + file.mimetype), false);
}

View File

@@ -1,7 +1,6 @@
const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
const endpoints = require('./endpoints');
const staticRoute = require('./static');
const messages = require('./messages');
@@ -53,7 +52,6 @@ module.exports = {
messages,
memories,
endpoints,
tokenizer,
assistants,
categories,
staticRoute,

View File

@@ -134,6 +134,16 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
hasCodeVerifier: !!flowState.codeVerifier,
});
/** Check if this flow has already been completed (idempotency protection) */
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (currentFlowState?.status === 'COMPLETED') {
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
flowId,
serverName,
});
return res.redirect(`/oauth/success?serverName=${encodeURIComponent(serverName)}`);
}
logger.debug('[MCP OAuth] Completing OAuth flow');
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);

View File

@@ -1,6 +1,7 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { ContentTypes } = require('librechat-data-provider');
const { unescapeLaTeX, countTokens } = require('@librechat/api');
const {
saveConvo,
getMessage,
@@ -13,7 +14,6 @@ const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { getConvosQueried } = require('~/models/Conversation');
const { countTokens } = require('~/server/utils');
const { Message } = require('~/db/models');
const router = express.Router();
@@ -134,17 +134,32 @@ router.post('/artifact/:messageId', async (req, res) => {
return res.status(400).json({ error: 'Artifact index out of bounds' });
}
// Unescape LaTeX preprocessing done by the frontend
// The frontend escapes $ signs for display, but the database has unescaped versions
const unescapedOriginal = unescapeLaTeX(original);
const unescapedUpdated = unescapeLaTeX(updated);
const targetArtifact = artifacts[index];
let updatedText = null;
if (targetArtifact.source === 'content') {
const part = message.content[targetArtifact.partIndex];
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
part.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
part.text = updatedText;
}
} else {
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
updatedText = replaceArtifactContent(
message.text,
targetArtifact,
unescapedOriginal,
unescapedUpdated,
);
if (updatedText) {
message.text = updatedText;
}

View File

@@ -5,6 +5,7 @@ const {
markPublicPromptGroups,
buildPromptGroupFilter,
formatPromptGroupsResponse,
safeValidatePromptGroupUpdate,
createEmptyPromptGroupsResponse,
filterAccessibleIdsBySharedLogic,
} = require('@librechat/api');
@@ -344,7 +345,16 @@ const patchPromptGroup = async (req, res) => {
if (req.user.role === SystemRoles.ADMIN) {
delete filter.author;
}
const promptGroup = await updatePromptGroup(filter, req.body);
const validationResult = safeValidatePromptGroupUpdate(req.body);
if (!validationResult.success) {
return res.status(400).send({
error: 'Invalid request body',
details: validationResult.error.errors,
});
}
const promptGroup = await updatePromptGroup(filter, validationResult.data);
res.status(200).send(promptGroup);
} catch (error) {
logger.error(error);

View File

@@ -544,6 +544,169 @@ describe('Prompt Routes - ACL Permissions', () => {
});
});
describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
let testGroup;
beforeEach(async () => {
// Create a prompt group
testGroup = await PromptGroup.create({
name: 'Security Test Group',
category: 'security-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
// Grant owner permissions
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('should allow updating allowed fields (name, category, oneliner)', async () => {
const updateData = {
name: 'Updated Group Name',
category: 'updated-category',
oneliner: 'Updated description',
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(updateData)
.expect(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.category).toBe(updateData.category);
expect(response.body.oneliner).toBe(updateData.oneliner);
});
it('should reject request with author field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
author: testUsers.noAccess._id.toString(), // Try to change ownership
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
expect(response.body.details).toBeDefined();
});
it('should reject request with authorName field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
authorName: 'Malicious Author Name',
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with _id field (400 Bad Request)', async () => {
const newId = new ObjectId();
const maliciousUpdate = {
name: 'Legit Update',
_id: newId.toString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with productionId field (400 Bad Request)', async () => {
const newProductionId = new ObjectId();
const maliciousUpdate = {
name: 'Legit Update',
productionId: newProductionId.toString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with createdAt field (400 Bad Request)', async () => {
const maliciousDate = new Date('2020-01-01');
const maliciousUpdate = {
name: 'Legit Update',
createdAt: maliciousDate.toISOString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with __v field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
__v: 999,
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with multiple sensitive fields (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
author: testUsers.noAccess._id.toString(),
authorName: 'Hacker',
_id: new ObjectId().toString(),
productionId: new ObjectId().toString(),
createdAt: new Date('2020-01-01').toISOString(),
__v: 999,
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected with validation errors
expect(response.body.error).toBe('Invalid request body');
expect(response.body.details).toBeDefined();
expect(Array.isArray(response.body.details)).toBe(true);
});
});
describe('Pagination', () => {
beforeEach(async () => {
// Create multiple prompt groups for pagination testing

View File

@@ -1,19 +0,0 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { countTokens } = require('~/server/utils');
const router = express.Router();
router.post('/', requireJwtAuth, async (req, res) => {
try {
const { arg } = req.body;
const count = await countTokens(arg?.text ?? arg);
res.send({ count });
} catch (e) {
logger.error('[/tokenizer] Error counting tokens', e);
res.status(500).json('Error counting tokens');
}
});
module.exports = router;

View File

@@ -8,7 +8,12 @@ const {
deleteUserController,
getUserController,
} = require('~/server/controllers/UserController');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
const {
verifyEmailLimiter,
configMiddleware,
canDeleteAccount,
requireJwtAuth,
} = require('~/server/middleware');
const router = express.Router();
@@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
router.post('/verify', verifyEmailController);
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);

View File

@@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 404, message: errorMessage };
}
const { email, password, name, username } = user;
const { email, password, name, username, provider } = user;
let newUserId;
try {
@@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
const salt = bcrypt.genSaltSync(10);
const newUserData = {
provider: 'local',
provider: provider ?? 'local',
email,
username,
name,
@@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
* @param {string} [userId] - Optional MongoDB user ID for image path validation
* @returns {String} - access token
*/
const setOpenIDAuthTokens = (tokenset, res, userId) => {
const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
try {
if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return;
}
if (!tokenset.access_token || !tokenset.refresh_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
if (!tokenset.access_token) {
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
return;
}
res.cookie('refreshToken', tokenset.refresh_token, {
const refreshToken = tokenset.refresh_token || existingRefreshToken;
if (!refreshToken) {
logger.error('[setOpenIDAuthTokens] No refresh token available');
return;
}
res.cookie('refreshToken', refreshToken, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('openid_access_token', tokenset.access_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,

View File

@@ -109,7 +109,7 @@ async function getEndpointsConfig(req) {
* @returns {Promise<boolean>}
*/
const checkCapability = async (req, capability) => {
const isAgents = isAgentsEndpoint(req.body?.original_endpoint || req.body?.endpoint);
const isAgents = isAgentsEndpoint(req.body?.endpointType || req.body?.endpoint);
const endpointsConfig = await getEndpointsConfig(req);
const capabilities =
isAgents || endpointsConfig?.[EModelEndpoint.agents]?.capabilities != null

View File

@@ -1,5 +1,9 @@
const { isUserProvided, normalizeEndpointName } = require('@librechat/api');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { isUserProvided } = require('@librechat/api');
const {
EModelEndpoint,
extractEnvVariable,
normalizeEndpointName,
} = require('librechat-data-provider');
const { fetchModels } = require('~/server/services/ModelService');
const { getAppConfig } = require('./app');

View File

@@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
const serverTools = {};
const mcpDelimiter = Constants.mcp_delimiter;
if (tools == null || tools.length === 0) {
logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`);
return serverTools;
}
for (const tool of tools) {
const name = `${tool.name}${mcpDelimiter}${serverName}`;
serverTools[name] = {

View File

@@ -3,12 +3,14 @@ const {
primeResources,
getModelMaxTokens,
extractLibreChatParams,
filterFilesByEndpointConfig,
optionalChainWithEmptyCheck,
} = require('@librechat/api');
const {
ErrorTypes,
EModelEndpoint,
EToolResources,
paramEndpoints,
isAgentsEndpoint,
replaceSpecialVars,
providerEndpointMap,
@@ -71,6 +73,9 @@ const initializeAgent = async ({
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions);
const provider = agent.provider;
agent.endpoint = provider;
if (isInitialAgent && conversationId != null && resendFiles) {
const fileIds = (await getConvoFiles(conversationId)) ?? [];
/** @type {Set<EToolResources>} */
@@ -88,6 +93,19 @@ const initializeAgent = async ({
currentFiles = await processFiles(requestFiles);
}
if (currentFiles && currentFiles.length) {
let endpointType;
if (!paramEndpoints.has(agent.endpoint)) {
endpointType = EModelEndpoint.custom;
}
currentFiles = filterFilesByEndpointConfig(req, {
files: currentFiles,
endpoint: agent.endpoint,
endpointType,
});
}
const { attachments, tool_resources } = await primeResources({
req,
getFiles,
@@ -98,7 +116,6 @@ const initializeAgent = async ({
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
});
const provider = agent.provider;
const {
tools: structuredTools,
toolContextMap,
@@ -113,7 +130,6 @@ const initializeAgent = async ({
tool_resources,
})) ?? {};
agent.endpoint = provider;
const { getOptions, overrideProvider } = getProviderConfig({ provider, appConfig });
if (overrideProvider !== agent.provider) {
agent.provider = overrideProvider;

View File

@@ -1,5 +1,6 @@
const OpenAI = require('openai');
const { ProxyAgent } = require('undici');
const { isUserProvided } = require('@librechat/api');
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
const {
getUserKeyValues,
@@ -7,7 +8,6 @@ const {
checkUserKeyExpiry,
} = require('~/server/services/UserService');
const OAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;

View File

@@ -1,9 +1,4 @@
const {
resolveHeaders,
isUserProvided,
getOpenAIConfig,
getCustomEndpointConfig,
} = require('@librechat/api');
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
const {
CacheKeys,
ErrorTypes,
@@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
/** Intentionally excludes passing `body`, i.e. `req.body`, as
* values may not be accurate until `AgentClient` is initialized
*/
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
});
if (CUSTOM_API_KEY.match(envVarRegex)) {
throw new Error(`Missing API Key for ${endpoint}.`);
}
@@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
}
const customOptions = {
headers: resolvedHeaders,
headers: endpointConfig.headers,
addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams,

View File

@@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
});
});
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith({
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
/**
* Note: Request-based Header Resolution is deferred until right before LLM request is made
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
*/
it('stores original template headers for deferred resolution', async () => {
/**
* Note: Request-based Header Resolution is deferred until right before LLM request is made
* in the OpenAIClient or AgentClient, not during initialization.
* This test verifies that the initialize function completes successfully with optionsOnly flag,
* and that headers are passed through to be resolved later during the actual LLM request.
*/
const result = await initializeClient({
req: mockRequest,
res: mockResponse,
optionsOnly: true,
});
// Verify that options are returned for later use
expect(result).toBeDefined();
expect(result).toHaveProperty('useLegacyContent', true);
});
it('throws if endpoint config is missing', async () => {

View File

@@ -12,14 +12,13 @@ const initGoogle = require('~/server/services/Endpoints/google/initialize');
* @returns {boolean} - True if the provider is a known custom provider, false otherwise
*/
function isKnownCustomProvider(provider) {
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
provider?.toLowerCase() || '',
);
}
const providerConfigMap = {
[Providers.XAI]: initCustom,
[Providers.OLLAMA]: initCustom,
[Providers.DEEPSEEK]: initCustom,
[Providers.OPENROUTER]: initCustom,
[EModelEndpoint.openAI]: initOpenAI,

View File

@@ -1,9 +1,9 @@
const path = require('path');
const { v4 } = require('uuid');
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const {
Tools,
FileContext,
@@ -41,11 +41,12 @@ const processCodeOutput = async ({
const appConfig = req.config;
const currentDate = new Date();
const baseURL = getCodeBaseURL();
const basePath = getBasePath();
const fileExt = path.extname(name);
if (!fileExt || !imageExtRegex.test(name)) {
return {
filename: name,
filepath: `/api/files/code/download/${session_id}/${id}`,
filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
/** Note: expires 24 hours after creation */
expiresAt: currentDate.getTime() + 86400000,
conversationId,

View File

@@ -1,12 +1,14 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, validateImage } = require('@librechat/api');
const {
FileSources,
VisionModes,
ImageDetail,
ContentTypes,
EModelEndpoint,
mergeFileConfig,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -84,11 +86,15 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
* Encodes and formats the given files.
* @param {ServerRequest} req - The request object.
* @param {Array<MongoFile>} files - The array of files to encode and format.
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
* @param {object} params - Object containing provider/endpoint information
* @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
* @param {string} [params.endpoint] - Optional: The endpoint for the image
* @param {string} [mode] - Optional: The endpoint mode for the image.
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
*/
async function encodeAndFormat(req, files, endpoint, mode) {
async function encodeAndFormat(req, files, params, mode) {
const { provider, endpoint } = params;
const effectiveEndpoint = endpoint ?? provider;
const promises = [];
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
const encodingMethods = {};
@@ -134,7 +140,7 @@ async function encodeAndFormat(req, files, endpoint, mode) {
} catch (error) {
logger.error('Error processing image from blob storage:', error);
}
} else if (source !== FileSources.local && base64Only.has(endpoint)) {
} else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
const [_file, imageURL] = await preparePayload(req, file);
promises.push([_file, await fetchImageToBase64(imageURL)]);
continue;
@@ -148,6 +154,17 @@ async function encodeAndFormat(req, files, endpoint, mode) {
const formattedImages = await Promise.all(promises);
promises.length = 0;
/** Extract configured file size limit from fileConfig for this endpoint */
let configuredFileSizeLimit;
if (req.config?.fileConfig) {
const fileConfig = mergeFileConfig(req.config.fileConfig);
const endpointConfig = getEndpointFileConfig({
fileConfig,
endpoint: effectiveEndpoint,
});
configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
}
for (const [file, imageContent] of formattedImages) {
const fileMetadata = {
type: file.type,
@@ -168,6 +185,26 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
/** Validate image buffer against size limits */
if (file.height && file.width) {
const imageBuffer = imageContent.startsWith('http')
? null
: Buffer.from(imageContent, 'base64');
if (imageBuffer) {
const validation = await validateImage(
imageBuffer,
imageBuffer.length,
effectiveEndpoint,
configuredFileSizeLimit,
);
if (!validation.isValid) {
throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
}
}
}
const imagePart = {
type: ContentTypes.IMAGE_URL,
image_url: {
@@ -184,15 +221,19 @@ async function encodeAndFormat(req, files, endpoint, mode) {
continue;
}
if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) {
if (
effectiveEndpoint &&
effectiveEndpoint === EModelEndpoint.google &&
mode === VisionModes.generative
) {
delete imagePart.image_url;
imagePart.inlineData = {
mimeType: file.type,
data: imageContent,
};
} else if (endpoint && endpoint === EModelEndpoint.google) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
imagePart.image_url = imagePart.image_url.url;
} else if (endpoint && endpoint === EModelEndpoint.anthropic) {
} else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
imagePart.type = 'image';
imagePart.source = {
type: 'base64',

View File

@@ -15,6 +15,7 @@ const {
checkOpenAIStorage,
removeNullishValues,
isAssistantsEndpoint,
getEndpointFileConfig,
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
@@ -994,7 +995,7 @@ async function saveBase64Image(
*/
function filterFile({ req, image, isAvatar }) {
const { file } = req;
const { endpoint, file_id, width, height } = req.body;
const { endpoint, endpointType, file_id, width, height } = req.body;
if (!file_id && !isAvatar) {
throw new Error('No file_id provided');
@@ -1016,9 +1017,13 @@ function filterFile({ req, image, isAvatar }) {
const appConfig = req.config;
const fileConfig = mergeFileConfig(appConfig.fileConfig);
const { fileSizeLimit: sizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
const fileSizeLimit = isAvatar === true ? fileConfig.avatarSizeLimit : sizeLimit;
const endpointFileConfig = getEndpointFileConfig({
endpoint,
fileConfig,
endpointType,
});
const fileSizeLimit =
isAvatar === true ? fileConfig.avatarSizeLimit : endpointFileConfig.fileSizeLimit;
if (file.size > fileSizeLimit) {
throw new Error(
@@ -1028,7 +1033,10 @@ function filterFile({ req, image, isAvatar }) {
);
}
const isSupportedMimeType = fileConfig.checkType(file.mimetype, supportedMimeTypes);
const isSupportedMimeType = fileConfig.checkType(
file.mimetype,
endpointFileConfig.supportedMimeTypes,
);
if (!isSupportedMimeType) {
throw new Error('Unsupported file type');

View File

@@ -1,11 +1,14 @@
const axios = require('axios');
const { Providers } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
const { logAxiosError, inputSchema, processModelData, isUserProvided } = require('@librechat/api');
const {
CacheKeys,
defaultModels,
KnownEndpoints,
EModelEndpoint,
} = require('librechat-data-provider');
const { OllamaClient } = require('~/app/clients/OllamaClient');
const { isUserProvided } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
const { extractBaseURL } = require('~/utils');
@@ -68,7 +71,7 @@ const fetchModels = async ({
return models;
}
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) {
@@ -80,7 +83,9 @@ const fetchModels = async ({
try {
const options = {
headers: {},
headers: {
...(headers ?? {}),
},
timeout: 5000,
};
@@ -101,7 +106,7 @@ const fetchModels = async ({
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
}
const url = new URL(`${baseURL}${azure ? '' : '/models'}`);
const url = new URL(`${baseURL.replace(/\/+$/, '')}${azure ? '' : '/models'}`);
if (user && userIdQuery) {
url.searchParams.append('user', user);
}

View File

@@ -81,6 +81,70 @@ describe('fetchModels', () => {
);
});
it('should pass custom headers to the API request', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
};
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle null headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: null,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle undefined headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: undefined,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -372,6 +436,68 @@ describe('fetchModels with Ollama specific logic', () => {
});
});
describe('fetchModels URL construction with trailing slashes', () => {
beforeEach(() => {
axios.get.mockResolvedValue({
data: {
data: [{ id: 'model-1' }, { id: 'model-2' }],
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should not create double slashes when baseURL has a trailing slash', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1/',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should handle baseURL without trailing slash normally', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should handle baseURL with multiple trailing slashes', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1///',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should correctly append query params after stripping trailing slashes', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1/',
name: 'TestAPI',
userIdQuery: true,
});
expect(axios.get).toHaveBeenCalledWith(
'https://api.test.com/v1/models?user=user123',
expect.any(Object),
);
});
});
describe('splitAndTrim', () => {
it('should split a string by commas and trim each value', () => {
const input = ' model1, model2 , model3,model4 ';
@@ -410,6 +536,64 @@ describe('getAnthropicModels', () => {
const models = await getAnthropicModels();
expect(models).toEqual(['claude-1', 'claude-2']);
});
it('should use Anthropic-specific headers when fetching models', async () => {
delete process.env.ANTHROPIC_MODELS;
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }, { id: 'claude-4' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
it('should pass custom headers for Anthropic endpoint', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
};
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
});
describe('getGoogleModels', () => {

View File

@@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) {
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
if (!existingUser) {
existingUser = await findUser({ email: principal.email.toLowerCase() });
existingUser = await findUser({ email: principal.email });
}
if (existingUser) {

View File

@@ -1,5 +1,6 @@
const path = require('path');
const { v4 } = require('uuid');
const { countTokens, escapeRegExp } = require('@librechat/api');
const {
Constants,
ContentTypes,
@@ -8,7 +9,6 @@ const {
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { recordMessage, getMessages } = require('~/models/Message');
const { countTokens, escapeRegExp } = require('~/server/utils');
const { spendTokens } = require('~/models/spendTokens');
const { saveConvo } = require('~/models/Conversation');

View File

@@ -18,6 +18,7 @@ const {
ImageVisionTool,
openapiToFunction,
AgentCapabilities,
validateActionDomain,
defaultAgentCapabilities,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
@@ -236,12 +237,26 @@ async function processRequiredActions(client, requiredActions) {
// Validate and parse OpenAPI spec
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
throw new Error(
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
);
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: client.req.user.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
// Process the OpenAPI spec
const { requestBuilders } = openapiToFunction(validationResult.spec);
@@ -525,10 +540,25 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
// Validate and parse OpenAPI spec once per action set
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec) {
if (!validationResult.spec || !validationResult.serverUrl) {
continue;
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,

View File

@@ -1,37 +0,0 @@
const { Tiktoken } = require('tiktoken/lite');
const { logger } = require('@librechat/data-schemas');
const p50k_base = require('tiktoken/encoders/p50k_base.json');
const cl100k_base = require('tiktoken/encoders/cl100k_base.json');
/**
* Counts the number of tokens in a given text using a specified encoding model.
*
* This function utilizes the 'Tiktoken' library to encode text based on the selected model.
* It supports two models, 'text-davinci-003' and 'gpt-3.5-turbo', each with its own encoding strategy.
* For 'text-davinci-003', the 'p50k_base' encoder is used, whereas for other models, the 'cl100k_base' encoder is applied.
* In case of an error during encoding, the error is logged, and the function returns 0.
*
* @async
* @param {string} text - The text to be tokenized. Defaults to an empty string if not provided.
* @param {string} modelName - The name of the model used for tokenizing. Defaults to 'gpt-3.5-turbo'.
* @returns {Promise<number>} The number of tokens in the provided text. Returns 0 if an error occurs.
* @throws Logs the error to a logger and rethrows if any error occurs during tokenization.
*/
const countTokens = async (text = '', modelName = 'gpt-3.5-turbo') => {
let encoder = null;
try {
const model = modelName.includes('text-davinci-003') ? p50k_base : cl100k_base;
encoder = new Tiktoken(model.bpe_ranks, model.special_tokens, model.pat_str);
const tokens = encoder.encode(text);
encoder.free();
return tokens.length;
} catch (e) {
logger.error('[countTokens]', e);
if (encoder) {
encoder.free();
}
return 0;
}
};
module.exports = countTokens;

View File

@@ -10,14 +10,6 @@ const {
const { sendEvent } = require('@librechat/api');
const partialRight = require('lodash/partialRight');
/** Helper function to escape special characters in regex
* @param {string} string - The string to escape.
* @returns {string} The escaped string.
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
const base = { message: true, initial: true };
@@ -181,7 +173,6 @@ function generateConfig(key, baseURL, endpoint) {
module.exports = {
handleText,
formatSteps,
escapeRegExp,
formatAction,
isUserProvided,
generateConfig,

View File

@@ -1,5 +1,4 @@
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
@@ -7,7 +6,6 @@ const files = require('./files');
module.exports = {
...handleText,
countTokens,
removePorts,
sendEmail,
...files,

View File

@@ -1,3 +1,4 @@
const cookies = require('cookie');
const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
@@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
passReqToCallback: true,
},
/**
* @param {import('@librechat/api').ServerRequest} req
* @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done
*/
async (payload, done) => {
async (req, payload, done) => {
try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload?.email,
@@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
await updateUser(user.id, updateData);
}
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const accessToken = parsedCookies.openid_access_token;
const refreshToken = parsedCookies.refreshToken;
user.federatedTokens = {
access_token: accessToken || rawToken,
id_token: rawToken,
refresh_token: refreshToken,
expires_at: payload.exp,
};
done(null, user);
} else {
logger.warn(

View File

@@ -543,7 +543,15 @@ async function setupOpenId() {
},
);
done(null, { ...user, tokenset });
done(null, {
...user,
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
});
} catch (err) {
logger.error('[openidStrategy] login failed', err);
done(err);

View File

@@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(() => false),
isEmailDomainAllowed: jest.fn(() => true),
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
getBalanceConfig: jest.fn(() => ({
enabled: false,
})),
@@ -446,6 +448,46 @@ describe('setupOpenId', () => {
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
it('should attach federatedTokens to user object for token propagation', async () => {
// Arrange - setup tokenset with access token, refresh token, and expiration
const tokensetWithTokens = {
...tokenset,
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
};
// Act - validate with the tokenset containing tokens
const { user } = await validate(tokensetWithTokens);
// Assert - verify federatedTokens object is attached with correct values
expect(user.federatedTokens).toBeDefined();
expect(user.federatedTokens).toEqual({
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
});
});
it('should include tokenset along with federatedTokens', async () => {
// Arrange
const tokensetWithTokens = {
...tokenset,
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
expires_at: 9999999999,
};
// Act
const { user } = await validate(tokensetWithTokens);
// Assert - both tokenset and federatedTokens should be present
expect(user.tokenset).toBeDefined();
expect(user.federatedTokens).toBeDefined();
expect(user.tokenset.access_token).toBe('test_access_token');
expect(user.federatedTokens.access_token).toBe('test_access_token');
});
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
// Act
const { user } = await validate(tokenset);

View File

@@ -172,6 +172,7 @@ describe('socialLogin', () => {
/** Verify both searches happened */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Email passed as-is; findUser implementation handles case normalization */
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
expect(findUser).toHaveBeenCalledTimes(2);

View File

@@ -40,6 +40,10 @@ module.exports = {
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com',
serverMetadata: jest.fn().mockReturnValue({
jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json',
end_session_endpoint: 'https://fake-issuer.com/logout',
}),
Client: jest.fn().mockImplementation(() => ({
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
callback: jest.fn().mockResolvedValue({

View File

@@ -1,17 +1,11 @@
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch');
const axios = require('axios');
// Mock dependencies
jest.mock('../../../../../models', () => ({
Files: {
find: jest.fn(),
},
jest.mock('axios');
jest.mock('@librechat/api', () => ({
generateShortLivedToken: jest.fn(),
}));
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({
queryVectors: jest.fn(),
}));
jest.mock('../../../../../config', () => ({
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
@@ -19,68 +13,220 @@ jest.mock('../../../../../config', () => ({
},
}));
const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud');
jest.mock('~/models/File', () => ({
getFiles: jest.fn().mockResolvedValue([]),
}));
describe('fileSearch.js - test only new file_id and page additions', () => {
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
}));
const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch');
const { generateShortLivedToken } = require('@librechat/api');
describe('fileSearch.js - tuple return validation', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.RAG_API_URL = 'http://localhost:8000';
});
// Test only the specific changes: file_id and page metadata additions
it('should add file_id and page to search result format', async () => {
const mockFiles = [{ file_id: 'test-file-123' }];
const mockResults = [
{
describe('error cases should return tuple with undefined as second value', () => {
it('should return tuple when no files provided', async () => {
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when JWT token generation fails', async () => {
generateShortLivedToken.mockReturnValue(null);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('There was an error authenticating the file search request.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when no valid results found', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
axios.post.mockRejectedValue(new Error('API Error'));
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No results found or errors occurred while searching the files.');
expect(result[1]).toBeUndefined();
});
});
describe('success cases should return tuple with artifact object', () => {
it('should return tuple with formatted results and sources artifact', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
const mockApiResponse = {
data: [
[
{
page_content: 'test content',
metadata: { source: 'test.pdf', page: 1 },
page_content: 'This is test content from the document',
metadata: { source: '/path/to/test.pdf', page: 1 },
},
0.3,
0.2,
],
[
{
page_content: 'Additional relevant content',
metadata: { source: '/path/to/test.pdf', page: 2 },
},
0.35,
],
],
},
];
};
queryVectors.mockResolvedValue(mockResults);
axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: mockFiles,
entity_id: 'agent-123',
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-123', filename: 'test.pdf' }],
entity_id: 'agent-456',
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(typeof formattedString).toBe('string');
expect(formattedString).toContain('File: test.pdf');
expect(formattedString).toContain('Relevance:');
expect(formattedString).toContain('This is test content from the document');
expect(formattedString).toContain('Additional relevant content');
expect(artifact).toBeDefined();
expect(artifact).toHaveProperty('file_search');
expect(artifact.file_search).toHaveProperty('sources');
expect(artifact.file_search).toHaveProperty('fileCitations', false);
expect(Array.isArray(artifact.file_search.sources)).toBe(true);
expect(artifact.file_search.sources.length).toBe(2);
const source = artifact.file_search.sources[0];
expect(source).toMatchObject({
type: 'file',
fileId: 'file-123',
fileName: 'test.pdf',
content: expect.any(String),
relevance: expect.any(Number),
pages: [1],
pageRelevance: { 1: expect.any(Number) },
});
});
// Mock the tool's function to return the formatted result
fileSearchTool.func = jest.fn().mockImplementation(async () => {
// Simulate the new format with file_id and page
const formattedResults = [
{
filename: 'test.pdf',
content: 'test content',
distance: 0.3,
file_id: 'test-file-123', // NEW: added file_id
page: 1, // NEW: added page
},
];
it('should include file citations in description when enabled', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// NEW: Internal data section for processAgentResponse
const internalData = formattedResults
.map(
(result) =>
`File: ${result.filename}\nFile_ID: ${result.file_id}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nPage: ${result.page || 'N/A'}\nContent: ${result.content}\n`,
)
.join('\n---\n');
const mockApiResponse = {
data: [
[
{
page_content: 'Content with citations',
metadata: { source: '/path/to/doc.pdf', page: 3 },
},
0.15,
],
],
};
return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n<!-- INTERNAL_DATA_START -->\n${internalData}\n<!-- INTERNAL_DATA_END -->`;
axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-789', filename: 'doc.pdf' }],
fileCitations: true,
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('Anchor:');
expect(formattedString).toContain('\\ue202turn0file0');
expect(artifact.file_search.fileCitations).toBe(true);
});
const result = await fileSearchTool.func('test');
it('should handle multiple files correctly', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// Verify the new additions
expect(result).toContain('File_ID: test-file-123');
expect(result).toContain('Page: 1');
expect(result).toContain('<!-- INTERNAL_DATA_START -->');
expect(result).toContain('<!-- INTERNAL_DATA_END -->');
const mockResponse1 = {
data: [
[
{
page_content: 'Content from file 1',
metadata: { source: '/path/to/file1.pdf', page: 1 },
},
0.25,
],
],
};
const mockResponse2 = {
data: [
[
{
page_content: 'Content from file 2',
metadata: { source: '/path/to/file2.pdf', page: 1 },
},
0.15,
],
],
};
axios.post.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [
{ file_id: 'file-1', filename: 'file1.pdf' },
{ file_id: 'file-2', filename: 'file2.pdf' },
],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('file1.pdf');
expect(formattedString).toContain('file2.pdf');
expect(artifact.file_search.sources).toHaveLength(2);
// Results are sorted by distance (ascending), so file-2 (0.15) comes before file-1 (0.25)
expect(artifact.file_search.sources[0].fileId).toBe('file-2');
expect(artifact.file_search.sources[1].fileId).toBe('file-1');
});
});
});

View File

@@ -1828,7 +1828,7 @@
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<string>}
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
* @memberof typedefs
*/

View File

@@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-1.5'],
);
expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3'],
);
expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
);
@@ -662,7 +665,7 @@ describe('Meta Models Tests', () => {
test('should match Deepseek model variations', () => {
expect(getModelMaxTokens('deepseek-chat')).toBe(
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
maxTokensMap[EModelEndpoint.openAI]['deepseek-chat'],
);
expect(getModelMaxTokens('deepseek-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
@@ -674,6 +677,20 @@ describe('Meta Models Tests', () => {
maxTokensMap[EModelEndpoint.openAI]['deepseek.r1'],
);
});
test('should return 128000 context tokens for all DeepSeek models', () => {
expect(getModelMaxTokens('deepseek-chat')).toBe(128000);
expect(getModelMaxTokens('deepseek-reasoner')).toBe(128000);
expect(getModelMaxTokens('deepseek-r1')).toBe(128000);
expect(getModelMaxTokens('deepseek-v3')).toBe(128000);
expect(getModelMaxTokens('deepseek.r1')).toBe(128000);
});
test('should handle DeepSeek models with provider prefixes', () => {
expect(getModelMaxTokens('deepseek/deepseek-chat')).toBe(128000);
expect(getModelMaxTokens('openrouter/deepseek-reasoner')).toBe(128000);
expect(getModelMaxTokens('openai/deepseek-v3')).toBe(128000);
});
});
describe('matchModelName', () => {
@@ -702,11 +719,42 @@ describe('Meta Models Tests', () => {
});
test('should match Deepseek model variations', () => {
expect(matchModelName('deepseek-chat')).toBe('deepseek');
expect(matchModelName('deepseek-chat')).toBe('deepseek-chat');
expect(matchModelName('deepseek-coder')).toBe('deepseek');
});
});
describe('DeepSeek Max Output Tokens', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
test('should return correct max output tokens for deepseek-chat', () => {
expect(getModelMaxOutputTokens('deepseek-chat')).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.openAI)).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.custom)).toBe(8000);
});
test('should return correct max output tokens for deepseek-reasoner', () => {
expect(getModelMaxOutputTokens('deepseek-reasoner')).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.openAI)).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.custom)).toBe(64000);
});
test('should return correct max output tokens for deepseek-r1', () => {
expect(getModelMaxOutputTokens('deepseek-r1')).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-r1', EModelEndpoint.openAI)).toBe(64000);
});
test('should return correct max output tokens for deepseek base pattern', () => {
expect(getModelMaxOutputTokens('deepseek')).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-v3')).toBe(8000);
});
test('should handle DeepSeek models with provider prefixes for max output tokens', () => {
expect(getModelMaxOutputTokens('deepseek/deepseek-chat')).toBe(8000);
expect(getModelMaxOutputTokens('openrouter/deepseek-reasoner')).toBe(64000);
});
});
describe('processModelData with Meta models', () => {
test('should process Meta model data correctly', () => {
const input = {
@@ -775,6 +823,16 @@ describe('Grok Model Tests - Tokens', () => {
expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
});
test('should return correct tokens for Grok 4 Fast and Grok 4.1 Fast models', () => {
expect(getModelMaxTokens('grok-4-fast')).toBe(2000000);
expect(getModelMaxTokens('grok-4-1-fast-reasoning')).toBe(2000000);
expect(getModelMaxTokens('grok-4-1-fast-non-reasoning')).toBe(2000000);
});
test('should return correct tokens for Grok Code Fast model', () => {
expect(getModelMaxTokens('grok-code-fast-1')).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);
@@ -794,6 +852,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
// Grok 4 model
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
// Grok 4 Fast and 4.1 Fast models
expect(getModelMaxTokens('xai/grok-4-fast')).toBe(2000000);
expect(getModelMaxTokens('xai/grok-4-1-fast-reasoning')).toBe(2000000);
expect(getModelMaxTokens('xai/grok-4-1-fast-non-reasoning')).toBe(2000000);
// Grok Code Fast model
expect(getModelMaxTokens('xai/grok-code-fast-1')).toBe(256000);
});
});
@@ -817,6 +881,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
// Grok 4 model
expect(matchModelName('grok-4-0709')).toBe('grok-4');
// Grok 4 Fast and 4.1 Fast models
expect(matchModelName('grok-4-fast')).toBe('grok-4-fast');
expect(matchModelName('grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
expect(matchModelName('grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
// Grok Code Fast model
expect(matchModelName('grok-code-fast-1')).toBe('grok-code-fast');
});
test('should match Grok model variations with prefixes', () => {
@@ -838,6 +908,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
// Grok 4 model
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
// Grok 4 Fast and 4.1 Fast models
expect(matchModelName('xai/grok-4-fast')).toBe('grok-4-fast');
expect(matchModelName('xai/grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
expect(matchModelName('xai/grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
// Grok Code Fast model
expect(matchModelName('xai/grok-code-fast-1')).toBe('grok-code-fast');
});
});
});
@@ -861,6 +937,15 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct context length for Claude Opus 4.5', () => {
expect(getModelMaxTokens('claude-opus-4-5', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
expect(getModelMaxTokens('claude-opus-4-5')).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -880,6 +965,25 @@ describe('Claude Model Tests', () => {
});
});
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
expect(modelKey).toBe('claude-opus-4-5');
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
});
it('should match model names correctly for Claude Haiku 4.5', () => {
const modelVariations = [
'claude-haiku-4-5',
@@ -895,6 +999,21 @@ describe('Claude Model Tests', () => {
});
});
it('should match model names correctly for Claude Opus 4.5', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-5');
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',

View File

@@ -1,4 +1,4 @@
/** v0.8.1-rc1 */
/** v0.8.1-rc2 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',
@@ -41,7 +41,6 @@ module.exports = {
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.1-rc1",
"version": "v0.8.1-rc2",
"description": "",
"type": "module",
"scripts": {
@@ -64,6 +64,7 @@
"copy-to-clipboard": "^3.3.3",
"cross-env": "^7.0.3",
"date-fns": "^3.3.1",
"dompurify": "^3.3.0",
"downloadjs": "^1.4.7",
"export-from-json": "^1.7.2",
"filenamify": "^6.0.0",
@@ -93,7 +94,7 @@
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.2",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@@ -135,10 +136,10 @@
"babel-plugin-root-import": "^6.6.0",
"babel-plugin-transform-import-meta": "^2.3.2",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jest": "^29.1.0",
"fs-extra": "^11.3.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-file-loader": "^1.0.3",
@@ -147,7 +148,6 @@
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
@@ -51,6 +52,7 @@ const App = () => {
<ToastProvider>
<DndProvider backend={HTML5Backend}>
<RouterProvider router={router} />
<WakeLockManager />
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
<Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />

View File

@@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils';
interface ArtifactsContextValue {
export interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
@@ -12,10 +12,15 @@ interface ArtifactsContextValue {
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
interface ArtifactsProviderProps {
children: React.ReactNode;
value?: Partial<ArtifactsContextValue>;
}
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => {
const chatLatestMessageText = useMemo(() => {
return getLatestText({
messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null,
@@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
} as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
const defaultContextValue = useMemo<ArtifactsContextValue>(
() => ({
isSubmitting,
latestMessageText,
latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null,
}),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId],
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value],
);
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { getEndpointField } from '~/utils/endpoints';
import { useChatContext } from './ChatContext';
interface DragDropContextValue {

View File

@@ -1,29 +1,76 @@
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext, useState, useMemo } from 'react';
interface EditorContextType {
/**
* Mutation state context - for components that need to know about save/edit status
* Separated from code state to prevent unnecessary re-renders
*/
interface MutationContextType {
isMutating: boolean;
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* Code state context - for components that need the current code content
* Changes frequently (on every keystroke), so only subscribe if needed
*/
interface CodeContextType {
currentCode?: string;
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const EditorContext = createContext<EditorContextType | undefined>(undefined);
const MutationContext = createContext<MutationContextType | undefined>(undefined);
const CodeContext = createContext<CodeContextType | undefined>(undefined);
/**
* Provides editor state management for artifact code editing
* Split into two contexts to prevent unnecessary re-renders:
* - MutationContext: for save/edit status (changes rarely)
* - CodeContext: for code content (changes on every keystroke)
*/
export function EditorProvider({ children }: { children: React.ReactNode }) {
const [isMutating, setIsMutating] = useState(false);
const [currentCode, setCurrentCode] = useState<string | undefined>();
const mutationValue = useMemo(() => ({ isMutating, setIsMutating }), [isMutating]);
const codeValue = useMemo(() => ({ currentCode, setCurrentCode }), [currentCode]);
return (
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
{children}
</EditorContext.Provider>
<MutationContext.Provider value={mutationValue}>
<CodeContext.Provider value={codeValue}>{children}</CodeContext.Provider>
</MutationContext.Provider>
);
}
export function useEditorContext() {
const context = useContext(EditorContext);
/**
* Hook to access mutation state only
* Use this when you only need to know about save/edit status
*/
export function useMutationState() {
const context = useContext(MutationContext);
if (context === undefined) {
throw new Error('useEditorContext must be used within an EditorProvider');
throw new Error('useMutationState must be used within an EditorProvider');
}
return context;
}
/**
* Hook to access code state only
* Use this when you need the current code content
*/
export function useCodeState() {
const context = useContext(CodeContext);
if (context === undefined) {
throw new Error('useCodeState must be used within an EditorProvider');
}
return context;
}
/**
* @deprecated Use useMutationState() and/or useCodeState() instead
* This hook causes components to re-render on every keystroke
*/
export function useEditorContext() {
const mutation = useMutationState();
const code = useCodeState();
return { ...mutation, ...code };
}

View File

@@ -41,4 +41,8 @@ export type AgentForm = {
recursion_limit?: number;
support_contact?: SupportContact;
category: string;
// Avatar management fields
avatar_file?: File | null;
avatar_preview?: string | null;
avatar_action?: 'upload' | 'reset' | null;
} & TAgentCapabilities;

View File

@@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
import { logger, extractContent, isArtifactRoute } from '~/utils';
import { artifactsState } from '~/store/artifacts';
import { logger, extractContent } from '~/utils';
import ArtifactButton from './ArtifactButton';
export const artifactPlugin: Pluggable = () => {
@@ -88,7 +88,7 @@ export function Artifact({
lastUpdateTime: now,
};
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return setArtifact(currentArtifact);
}

View File

@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { getFileType, logger } from '~/utils';
import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@@ -13,8 +13,9 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const location = useLocation();
const setVisible = useSetRecoilState(store.artifactsVisibility);
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const [currentArtifactId, setCurrentArtifactId] = useRecoilState(store.currentArtifactId);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const isSelected = artifact?.id === currentArtifactId;
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
const debouncedSetVisibleRef = useRef(
@@ -36,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return;
}
if (!location.pathname.includes('/c/')) {
if (!isArtifactRoute(location.pathname)) {
return;
}
@@ -54,35 +55,52 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return (
<div className="group relative my-4 rounded-xl text-sm text-text-primary">
<button
type="button"
onClick={() => {
if (!location.pathname.includes('/c/')) {
{(() => {
const handleClick = () => {
if (isSelected) {
resetCurrentArtifactId();
setVisible(false);
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);
}}
className="relative overflow-hidden rounded-xl border border-border-medium transition-all duration-300 hover:border-border-xheavy hover:shadow-lg"
>
<div className="w-fit bg-surface-tertiary p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">
{localize('com_ui_artifact_click')}
};
const buttonClass = cn(
'relative overflow-hidden rounded-xl transition-all duration-300 hover:border-border-medium hover:bg-surface-hover hover:shadow-lg active:scale-[0.98]',
{
'border-border-medium bg-surface-hover shadow-lg': isSelected,
'border-border-light bg-surface-tertiary shadow-sm': !isSelected,
},
);
const actionLabel = isSelected
? localize('com_ui_click_to_close')
: localize('com_ui_artifact_click');
return (
<button type="button" onClick={handleClick} className={buttonClass}>
<div className="w-fit p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview fileType={fileType} className="relative" />
<div className="overflow-hidden text-left">
<div className="truncate font-medium">{artifact.title}</div>
<div className="truncate text-text-secondary">{actionLabel}</div>
</div>
</div>
</div>
</div>
</div>
</button>
</button>
);
})()}
<br />
</div>
);

View File

@@ -1,5 +1,7 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
import debounce from 'lodash/debounce';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { KeyBinding } from '@codemirror/view';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import {
useSandpack,
SandpackCodeEditor,
@@ -10,116 +12,143 @@ import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import { useEditorContext, useArtifactsContext } from '~/Providers';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
const createDebouncedMutation = (
callback: (params: {
index: number;
messageId: string;
original: string;
updated: string;
}) => void,
) => debounce(callback, 500);
const CodeEditor = ({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating, setCurrentCode } = useEditorContext();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const mutationCallback = useCallback(
(params: { index: number; messageId: string; original: string; updated: string }) => {
editArtifact.mutate(params);
},
[editArtifact],
);
const debouncedMutation = useMemo(
() => createDebouncedMutation(mutationCallback),
[mutationCallback],
);
useEffect(() => {
if (readOnly) {
return;
}
if (isMutating) {
return;
}
if (artifact.index == null) {
return;
}
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
const isNotOriginal =
currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdate == null
? true
: currentCode != null && currentCode.trim() !== currentUpdate.trim();
if (artifact.content && isNotOriginal && isNotRepeated) {
setCurrentCode(currentCode);
debouncedMutation({
index: artifact.index,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: currentCode,
});
}
return () => {
debouncedMutation.cancel();
};
}, [
const CodeEditor = memo(
({
fileKey,
artifact.index,
artifact.content,
artifact.messageId,
readOnly,
isMutating,
currentUpdate,
setIsMutating,
sandpack.files,
setCurrentCode,
debouncedMutation,
]);
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const { setCurrentCode } = useCodeState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
className="hljs language-javascript bg-black"
/>
);
};
/**
* Create stable debounced mutation that doesn't depend on changing callbacks
* Use refs to always access the latest values without recreating the debounce
*/
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
useEffect(() => {
artifactRef.current = artifact;
}, [artifact]);
useEffect(() => {
isMutatingRef.current = isMutating;
}, [isMutating]);
useEffect(() => {
currentUpdateRef.current = currentUpdate;
}, [currentUpdate]);
useEffect(() => {
editArtifactRef.current = editArtifact;
}, [editArtifact]);
useEffect(() => {
setCurrentCodeRef.current = setCurrentCode;
}, [setCurrentCode]);
/**
* Create debounced mutation once - never recreate it
* All values are accessed via refs so they're always current
*/
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly) {
return;
}
if (isMutatingRef.current) {
return;
}
if (artifactRef.current.index == null) {
return;
}
const artifact = artifactRef.current;
const artifactIndex = artifact.index;
const isNotOriginal =
code && artifact.content != null && code.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdateRef.current == null
? true
: code != null && code.trim() !== currentUpdateRef.current.trim();
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: artifactIndex,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: code,
});
}
}, 500),
[readOnly],
);
/**
* Listen to Sandpack file changes and trigger debounced mutation
*/
useEffect(() => {
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode) {
debouncedMutation(currentCode);
}
}, [sandpack.files, fileKey, debouncedMutation]);
/**
* Cleanup: cancel pending mutations when component unmounts or artifact changes
*/
useEffect(() => {
return () => {
debouncedMutation.cancel();
};
}, [artifact.id, debouncedMutation]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
extensions={[autocompletion()]}
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
className="hljs language-javascript bg-black"
/>
);
},
);
export const ArtifactCodeEditor = function ({
files,
@@ -128,6 +157,7 @@ export const ArtifactCodeEditor = function ({
artifact,
editorRef,
sharedProps,
readOnly: externalReadOnly,
}: {
fileKey: string;
artifact: Artifact;
@@ -135,6 +165,7 @@ export const ArtifactCodeEditor = function ({
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
readOnly?: boolean;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
@@ -148,10 +179,11 @@ export const ArtifactCodeEditor = function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
const [readOnly, setReadOnly] = useState(initialReadOnly);
useEffect(() => {
setReadOnly(isSubmitting ?? false);
}, [isSubmitting]);
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
}, [isSubmitting, externalReadOnly]);
if (Object.keys(files).length === 0) {
return null;

View File

@@ -1,10 +1,9 @@
import React, { memo, useMemo } from 'react';
import {
SandpackPreview,
SandpackProvider,
import React, { memo, useMemo, type MutableRefObject } from 'react';
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
import type {
SandpackProviderProps,
SandpackPreviewRef,
} from '@codesandbox/sandpack-react/unstyled';
import type { SandpackPreviewRef, PreviewProps } from '@codesandbox/sandpack-react/unstyled';
import type { TStartupConfig } from 'librechat-data-provider';
import type { ArtifactFiles } from '~/common';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
@@ -22,7 +21,7 @@ export const ArtifactPreview = memo(function ({
fileKey: string;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
previewRef: MutableRefObject<SandpackPreviewRef>;
currentCode?: string;
startupConfig?: TStartupConfig;
}) {
@@ -36,9 +35,7 @@ export const ArtifactPreview = memo(function ({
}
return {
...files,
[fileKey]: {
code,
},
[fileKey]: { code },
};
}, [currentCode, files, fileKey]);
@@ -46,12 +43,10 @@ export const ArtifactPreview = memo(function ({
if (!startupConfig) {
return sharedOptions;
}
const _options: typeof sharedOptions = {
return {
...sharedOptions,
bundlerURL: template === 'static' ? startupConfig.staticBundlerURL : startupConfig.bundlerURL,
};
return _options;
}, [startupConfig, template]);
if (Object.keys(artifactFiles).length === 0) {
@@ -60,10 +55,7 @@ export const ArtifactPreview = memo(function ({
return (
<SandpackProvider
files={{
...artifactFiles,
...sharedFiles,
}}
files={{ ...artifactFiles, ...sharedFiles }}
options={options}
{...sharedProps}
template={template}

Some files were not shown because too many files have changed in this diff Show More