Compare commits

...

46 Commits

Author SHA1 Message Date
Rakshit
af4520df29 Merge branch 'main' into docs/azure-instance-name-clarification 2025-11-05 21:37:57 +05:30
constanttime
fabce6f823 docs: clarify Azure instanceName format for speech services
- Add Azure OpenAI STT/TTS examples to librechat.example.yaml
- Clarify that instanceName should be the <NAME> part only (e.g., 'my-instance')
- Not the full URL (e.g., 'my-instance.cognitiveservices.azure.com')
- Add note that full domain format is still supported for backward compatibility

Examples:
- Correct: instanceName: 'my-instance'
- Also works: instanceName: 'my-instance.cognitiveservices.azure.com'

Related to #10283
2025-11-05 21:35:45 +05:30
Danny Avila
0f4222a908 🪞 fix: Prevent Revoked Blob URLs in Uploaded Images (FileRow) (#10361) 2025-11-05 10:28:06 -05:00
Rakshit
772b706e20 🎙️ fix: Azure OpenAI Speech-to-Text 400 Bad Request Error (#10355) 2025-11-05 10:27:34 -05:00
Danny Avila
06fcf79d56 🛂 feat: Social Login by Provider ID First then Email (#10358) 2025-11-05 09:20:35 -05:00
Eduardo Cruz Guedes
c9e1127b85 🌅 docs: Add OpenAI Image Gen Env Vars (#10335) 2025-11-04 13:52:47 -05:00
Max Sanna
14e4941367 📎 fix: Document Uploads for Custom Endpoints (#10336)
* Fixed upload to provider for custom endpoints + unit tests

* fix: add support back for agents to be able to use Upload to Provider with supported providers

* ci: add test for agents endpoint still recognizing document supported providers

* chore: address ESLint suggestions

* Improved unit tests

* Linting error on unit tests fixed

---------

Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
2025-11-04 13:40:24 -05:00
Theo N. Truong
ce7e6edad8 🔄 refactor: MCP Registry System with Distributed Caching (#10191)
* refactor: Restructure MCP registry system with caching

- Split MCPServersRegistry into modular components:
  - MCPServerInspector: handles server inspection and health checks
  - MCPServersInitializer: manages server initialization logic
  - MCPServersRegistry: simplified registry coordination
- Add distributed caching layer:
  - ServerConfigsCacheRedis: Redis-backed configuration cache
  - ServerConfigsCacheInMemory: in-memory fallback cache
  - RegistryStatusCache: distributed leader election state
- Add promise utilities (withTimeout) replacing Promise.race patterns
- Add comprehensive cache integration tests for all cache implementations
- Remove unused MCPManager.getAllToolFunctions method

* fix: Update OAuth flow to include user-specific headers

* chore: Update Jest configuration to ignore additional test files

- Added patterns to ignore files ending with .helper.ts and .helper.d.ts in testPathIgnorePatterns for cleaner test runs.

* fix: oauth headers in callback

* chore: Update Jest testPathIgnorePatterns to exclude helper files

- Modified testPathIgnorePatterns in package.json to ignore files ending with .helper.ts and .helper.d.ts for cleaner test execution.

* ci: update test mocks

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-31 15:00:21 -04:00
github-actions[bot]
961f87cfda 🌍 i18n: Update translation.json with latest translations (#10323)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-31 14:36:32 -04:00
Danny Avila
9b4c4cafb6 🧠 refactor: Improve Reasoning Component Structure and UX (#10320)
* refactor: Reasoning components with independent toggle buttons

- Refactored ThinkingButton to remove unnecessary state and props.
- Updated ContentParts to simplify content rendering and remove hover handling.
- Improved Reasoning component to include independent toggle functionality for each THINK part.
- Adjusted styles for better layout consistency and user experience.

* refactor: isolate hover effects for Reasoning

- Updated ThinkingButton to improve hover effects and layout consistency.
- Refactored Reasoning component to include a new wrapper class for better styling.
- Adjusted icon visibility and transitions for a smoother user experience.

* fix: Prevent rendering of empty messages in Chat component

- Added a check to skip rendering if the message text is only whitespace, improving the user interface by avoiding empty containers.

* chore: Replace div with fragment in Thinking component for cleaner markup

* chore: move Thinking component to Content Parts directory

* refactor: prevent rendering of whitespace-only text in Part component only for edge cases
2025-10-31 13:05:12 -04:00
Marco Beretta
c0f1cfcaba 💡 feat: Improve Reasoning Content UI, copy-to-clipboard, and error handling (#10278)
*  feat: Refactor error handling and improve loading states in MessageContent component

*  feat: Enhance Thinking and ContentParts components with improved hover functionality and clipboard support

* fix: Adjust padding in Thinking and ContentParts components for consistent layout

*  feat: Add response label and improve message editing UI with contextual indicators

*  feat: Add isEditing prop to Feedback and Fork components for improved editing state handling

* refactor: Remove isEditing prop from Feedback and Fork components for cleaner state management

* refactor: Migrate state management from Recoil to Jotai for font size and show thinking features

* refactor: Separate ToggleSwitch into RecoilToggle and JotaiToggle components for improved clarity and state management

* refactor: Remove unnecessary comments in ToggleSwitch and MessageContent components for cleaner code

* chore: reorder import statements in Thinking.tsx

* chore: reorder import statement in EditTextPart.tsx

* chore: reorder import statement

* chore: Reorganize imports in ToggleSwitch.tsx

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-30 17:14:38 -04:00
Federico Ruggi
ea45d0b9c6 🏷️ fix: Add user ID to MCP tools cache keys (#10201)
* add user id to mcp tools cache key

* tests

* clean up redundant tests

* remove unused imports
2025-10-30 17:09:56 -04:00
Theo N. Truong
8f4705f683 👑 feat: Distributed Leader Election with Redis for Multi-instance Coordination (#10189)
* 🔧 refactor: Move GLOBAL_PREFIX_SEPARATOR to cacheConfig for consistency

* 👑 feat: Implement distributed leader election using Redis
2025-10-30 17:08:04 -04:00
Danny Avila
1e53ffa7ea v0.8.1-rc1 (#10316)
*  v0.8.1-rc1

* chore: Update CONFIG_VERSION to 1.3.1
2025-10-30 16:36:54 -04:00
github-actions[bot]
65281464fc 🌍 i18n: Update translation.json with latest translations (#10315)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-30 16:23:37 -04:00
Peter
658921af88 🔗 fix: Provided Azure Base URL Construction for Responses API (#10289)
* fix: response api works with azure base url configured

* add unit test

---------

Co-authored-by: Peter Rothlaender <peter.rothlaender@ginkgo.com>
2025-10-30 14:57:03 -04:00
Sean McGrath
ce6456c39f 🎨 fix: Update artifacts Tailwind to official CDN (#10301)
Co-authored-by: Sean McGrath <sean.mcgrath@holmesgroup.com>
2025-10-30 14:49:00 -04:00
Danny Avila
d904b281f1 🦙 fix: Ollama Custom Headers (#10314)
* 🦙 fix: Ollama Custom Headers

* chore: Correct import order for resolveHeaders in OllamaClient.js

* fix: Improve error logging for Ollama API model fetch failure

* ci: update Ollama model fetch tests

* ci: Add unit test for passing headers and user object to Ollama fetchModels
2025-10-30 14:48:10 -04:00
github-actions[bot]
5e35b7d09d 🌍 i18n: Update translation.json with latest translations (#10298)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 16:41:55 -04:00
Danny Avila
6adb425780 🔄 refactor: Max tokens handling in Agent Initialization (#10299)
* Refactored the logic for determining max output tokens in the agent initialization process.
* Changed variable names for clarity, updating from `maxTokens` to `maxOutputTokens` to better reflect their purpose.
* Adjusted calculations for `maxContextTokens` to use the new `maxOutputTokens` variable.
2025-10-29 16:41:27 -04:00
Danny Avila
e6aeec9f25 🎚️ feat: Reasoning Parameters for Custom Endpoints (#10297) 2025-10-29 13:41:35 -04:00
Danny Avila
861ef98d29 📫 refactor: OpenID Email Claim Fallback (#10296)
* 📫 refactor: Enhance OpenID email Fallback

* Updated email retrieval logic to use preferred_username or upn if email is not available.
* Adjusted logging and user data assignment to reflect the new email handling approach.
* Ensured email domain validation checks the correct email source.

* 🔄 refactor: Update Email Domain Validation Logic

* Modified `isEmailDomainAllowed` function to return true for falsy emails and missing domain restrictions.
* Added new test cases to cover scenarios with and without domain restrictions.
* Ensured proper validation when domain restrictions are present.
2025-10-29 12:57:43 -04:00
poornapragnyah
05c706137e ✂️ fix: Trim Reasoning Tags from Titles and Delete Button Visibility (#10285)
* fix: Sanitize LLM titles by stripping <think> tags and fix modal overflow

* chore: linting

* chore: Simplify title sanitization by removing unnecessary variable assignment and import order

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-29 12:48:58 -04:00
github-actions[bot]
9fbc2afe40 🌍 i18n: Update translation.json with latest translations (#10282)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-29 12:38:43 -04:00
Danny Avila
8adef91cf5 🎚️ fix: Default Max Output Tokens for Claude 4+ Models (#10293) 2025-10-29 12:28:01 -04:00
Danny Avila
70ff6e94f2 🪢 feat: Add Langfuse Tracing Support (#10292)
* 📦 feat: `@librechat/agents` v2.4.87 for LangFuse Support

* 📦 chore: update @librechat/agents to v2.4.88 in package.json and package-lock.json

* 📦 chore: update @librechat/agents to v2.4.89

* feat: Add runName configuration to AgentClient and Memory agent for improved tracing
2025-10-29 12:23:09 -04:00
Danny Avila
0e05ff484f 🔄 refactor: OAI Image Edit Proxy, Speech Settings Handling, Import Query Data Usage (#10281)
* chore: correct startupConfig usage in ImportConversations component

* refactor: properly process configured speechToText and textToSpeech settings in getCustomConfigSpeech

* refactor: proxy configuration by utilizing HttpsProxyAgent for OpenAI Image Edits
2025-10-28 09:36:03 -04:00
github-actions[bot]
250209858a 🌍 i18n: Update translation.json with latest translations (#10274)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-28 09:00:19 -04:00
Daniel Paulus
9e77f835a6 🎛️ feat: Custom Environment Variable Support to RAG API Helm Chart (#10245)
* Possibility to add extra env values to the deployment

* Fix: Custom environment variables should be placed after the predefined environment variables
2025-10-28 08:37:04 -04:00
Danny Avila
7973cb42ef 🔃 refactor: Clear MCP only on Model Spec Selection without MCP Servers (#10273) 2025-10-27 20:33:51 -04:00
Dustin Healy
0446d0e190 fix: Address Accessibility Issues (#10260)
* chore: add i18n localization comment for AlwaysMakeProd component

* feat: enhance accessibility by adding aria-label and aria-labelledby to Switch component

* feat: add aria-labels for accessibility in Agent and Assistant avatar buttons

* fix: add switch aria-labels for accessibility in various components

* feat: add aria-labels and localization keys for accessibility in DataTable, DataTableColumnHeader, and OGDialogTemplate components

* chore: refactor out nested ternary

* feat: add aria-label to DataTable filter button for My Files modal

* feat: add aria-labels for Buttons and localization strings

* feat: add aria-labels to Checkboxes in Agent Builder

* feat: enhance accessibility by adding aria-label and aria-labelledby to Checkbox component

* feat: add aria-label to FileSearchCheckbox in Agent Builder

* feat: add aria-label to Prompts text input area

* feat: enhance accessibility by adding aria-label and aria-labelledby to TextAreaAutosize component

* feat: remove improper role: "list" prop from List in Conversations.tsx to enhance accessibility and stop aria rules conflicting within react-virtualized component

* feat: enhance accessibility by allowing tab navigation and adding ring highlights for conversation title editing accept/reject buttons

* feat: add aria-label to Copy Link button in the conversation share modal

* feat: add title to QR code svg in conversation share modal to  describe the image content

* feat: enhance accessibility by making Agent Avatar upload keyboard navigable and round out highlight border on focus

* feat: enhance accessibility by adding aria attributes around alerting users with screen readers to invalid email address inputs in the Agent Builder

* feat: add aria-labels to buttons in Advanced panel of Agent Builder

* feat: enhance accessibility by making FileUpload and Clear All buttons in PresetItems keyboard navigable

* feat: enchance accessiblity by indexing view and delete button aria-labels in shared links management modal to their specific chat titles

* feat: add border highlighting on focus for AnimatedSearchInput

* feat: add category description to aria-labels for prompts in ListCard

* feat: add proper scoping to rows and columns in table headers

* feat: add localized aria-labelling to EditTextPart's TextAreaAutosize component and base dynamic paramters panel components and their supporting translation keys

* feat: add localized aria-labels and aria-labelledBy to Checkbox components without them

* feat: add localized aria-labeledBy for endpoint settings Sliders

* feat: add localized aria-labels for TextareaAutosize components

* chore: remove unused i18n string

* feat: add localized aria-label for BookmarkForm Checkbox

* fix: add stopPropagation onKeyDown for Preview and Edit menu items in prompts that was causing the prompts to inadvertently be sent when triggered with keyboard navigation when Auto-send Prompts was toggled on

* fix: switch TableCell to TableHead for title cells according to harvard issue #789

* fix: add more descriptive localization key for file filter button in DataTable

* chore: remove self-explanatory code comment from RenameForm

* fix: remove stray bg-yellow highlight that was left in during debugging

* fix: add aria-label to model configurator panel back button

* fix: undo incorrect hoist of tool name split for aria-label and span in MCPInput

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-27 19:46:43 -04:00
Danny Avila
33d6b337bc 📛 feat: Chat Badges via Model Specs (#10272)
* refactor: remove `useChatContext` from `useSelectMention`, explicitly pass `conversation` object

* feat: ephemeral agents via model specs

* refactor: Sync Jotai state with ephemeral agent state, also when Ephemeral Agent has no MCP servers selected

* refactor: move `useUpdateEphemeralAgent` to store and clean up imports

* refactor: reorder imports and invalidate queries for mcpConnectionStatus in event handler

* refactor: replace useApplyModelSpecEffects with useApplyModelSpecAgents and update event handlers to use new agent template logic

* ci: update useMCPSelect test to verify mcpValues sync with empty ephemeralAgent.mcp
2025-10-27 19:46:30 -04:00
github-actions[bot]
64df54528d 🌍 i18n: Update translation.json with latest translations (#10259)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-27 19:45:37 -04:00
Max Sanna
d46dde4e01 👫 fix: Update Entra ID group retrieval to use getMemberGroups and add pagination support (#10199) 2025-10-26 21:58:29 -04:00
Federico Ruggi
13b784a3e6 🧼 fix: Sanitize MCP Server Selection Against Config (#10243)
* filter out unavailable servers

* bump render time

* Fix import path for useGetStartupConfig

* refactor: Change configuredServers to use Set for improved filtering of available MCPs

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-26 21:48:23 -04:00
Danny Avila
90e610ceda 🎪 refactor: Allow Last Model Spec Selection without Prioritizing (#10258)
* refactor: Default Model Spec Retrieval Logic, allowing last selected spec on new chat if last selection was a spec

* chore: Replace hardcoded 'new' conversation ID with Constants.NEW_CONVO for consistency

* chore: remove redundant condition for model spec preset selection in useNewConvo hook
2025-10-26 21:37:55 -04:00
github-actions[bot]
cbbbde3681 🌍 i18n: Update translation.json with latest translations (#10229)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-26 21:32:38 -04:00
Sebastien Bruel
05c9195197 🛠️ fix: Agent Tools Modal on First-Time Agent Creation (#10234) 2025-10-26 21:30:05 -04:00
Danny Avila
9495520f6f 📦 chore: update vite to v6.4.1 and @playwright/test to v1.56.1 (#10227)
* 📦 chore: update vite to v6.4.1

* 📦 chore: update @playwright/test to v1.56.1
2025-10-22 22:22:57 +02:00
Sebastien Bruel
87d7ee4b0e 🌐 feat: Configurable Domain and Port for Vite Dev Server (#10180) 2025-10-22 22:04:49 +02:00
Danny Avila
d8d5d59d92 ♻️ refactor: Message Cache Clearing Logic into Reusable Helper (#10226) 2025-10-22 22:02:29 +02:00
Danny Avila
e3d33fed8d 📦 chore: update @librechat/agents to v2.4.86 (#10216) 2025-10-22 16:51:58 +02:00
github-actions[bot]
cbf52eabe3 🌍 i18n: Update translation.json with latest translations (#10175)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-22 09:53:21 +02:00
Danny Avila
36f0365fd4 🧮 feat: Enhance Model Pricing Coverage and Pattern Matching (#10173)
* updated gpt5-pro

it is here and on openrouter
https://platform.openai.com/docs/models/gpt-5-pro

* feat: Add gpt-5-pro pricing
- Implemented handling for the new gpt-5-pro model in the getValueKey function.
- Updated tests to ensure correct behavior for gpt-5-pro across various scenarios.
- Adjusted token limits and multipliers for gpt-5-pro in the tokens utility files.
- Enhanced model matching functionality to include gpt-5-pro variations.

* refactor: optimize model pricing and validation logic

- Added new model pricing entries for llama2, llama3, and qwen variants in tx.js.
- Updated tokenValues to include additional models and their pricing structures.
- Implemented validation tests in tx.spec.js to ensure all models resolve correctly to pricing.
- Refactored getValueKey function to improve model matching and resolution efficiency.
- Removed outdated model entries from tokens.ts to streamline pricing management.

* fix: add missing pricing

* chore: update model pricing for qwen and gemma variants

* chore: update model pricing and add validation for context windows

- Removed outdated model entries from tx.js and updated tokenValues with new models.
- Added a test in tx.spec.js to ensure all models with pricing have corresponding context windows defined in tokens.ts.
- Introduced 'command-text' model pricing in tokens.ts to maintain consistency across model definitions.

* chore: update model names and pricing for AI21 and Amazon models

- Refactored model names in tx.js for AI21 and Amazon models to remove versioning and improve consistency.
- Updated pricing values in tokens.ts to reflect the new model names.
- Added comprehensive tests in tx.spec.js to validate pricing for both short and full model names across AI21 and Amazon models.

* feat: add pricing and validation for Claude Haiku 4.5 model

* chore: increase default max context tokens to 18000 for agents

* feat: add Qwen3 model pricing and validation tests

* chore: reorganize and update Qwen model pricing in tx.js and tokens.ts

---------

Co-authored-by: khfung <68192841+khfung@users.noreply.github.com>
2025-10-19 15:23:27 +02:00
Federico Ruggi
589f119310 🩹 fix: Wrap Attempt to Reconnect OAuth MCP Servers (#10172) 2025-10-18 05:54:05 -04:00
Marco Beretta
d41b07c0af ♻️ refactor: Replace fontSize Recoil atom with Jotai (#10171)
* fix: reapply chat font size on load

* refactor: streamline font size handling in localStorage

* fix: update matchMedia mock to accurately reflect desktop and touchscreen capabilities

* refactor: implement Jotai for font size management and initialize on app load

- Replaced Recoil with Jotai for font size state management across components.
- Added a new `fontSize` atom to handle font size changes and persist them in localStorage.
- Implemented `initializeFontSize` function to apply saved font size on app load.
- Updated relevant components to utilize the new font size atom.

---------

Co-authored-by: ddooochii <ddooochii@gmail.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-18 05:50:34 -04:00
275 changed files with 9386 additions and 2122 deletions

View File

@@ -254,6 +254,10 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT=
# OpenAI Image Tools Customization
#----------------
# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool
# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool
# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments
# IMAGE_GEN_OAI_DESCRIPTION=
# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present
# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present
# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool
@@ -702,6 +706,16 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)
# LEADER_LEASE_DURATION=25
# Interval in seconds at which the leader renews its lease (default: 10)
# LEADER_RENEW_INTERVAL=10
# Maximum number of retry attempts when renewing the lease fails (default: 3)
# LEADER_RENEW_ATTEMPTS=3
# Delay in seconds between retry attempts when renewing the lease (default: 0.5)
# LEADER_RENEW_RETRY_DELAY=0.5
#==================================================#
# Others #
#==================================================#

View File

@@ -8,12 +8,14 @@ on:
- release/*
paths:
- 'packages/api/src/cache/**'
- 'packages/api/src/cluster/**'
- 'packages/api/src/mcp/**'
- 'redis-config/**'
- '.github/workflows/cache-integration-tests.yml'
jobs:
cache_integration_tests:
name: Run Cache Integration Tests
name: Integration Tests that use actual Redis Cache
timeout-minutes: 30
runs-on: ubuntu-latest
@@ -66,7 +68,23 @@ jobs:
USE_REDIS: true
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
run: npm run test:cache-integration:core
- name: Run cluster 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: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
- name: Stop Redis Cluster
if: always()

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ const { z } = require('zod');
const axios = require('axios');
const { Ollama } = require('ollama');
const { sleep } = require('@librechat/agents');
const { logAxiosError } = require('@librechat/api');
const { resolveHeaders } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { deriveBaseURL } = require('~/utils');
@@ -44,6 +44,7 @@ class OllamaClient {
constructor(options = {}) {
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
this.headers = options.headers ?? {};
/** @type {Ollama} */
this.client = new Ollama({ host });
}
@@ -51,27 +52,32 @@ class OllamaClient {
/**
* Fetches Ollama models from the specified base API path.
* @param {string} baseURL
* @param {Object} [options] - Optional configuration
* @param {Partial<IUser>} [options.user] - User object for header resolution
* @param {Record<string, string>} [options.headers] - Headers to include in the request
* @returns {Promise<string[]>} The Ollama models.
* @throws {Error} Throws if the Ollama API request fails
*/
static async fetchModels(baseURL) {
let models = [];
static async fetchModels(baseURL, options = {}) {
if (!baseURL) {
return models;
}
try {
const ollamaEndpoint = deriveBaseURL(baseURL);
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
timeout: 5000,
});
models = response.data.models.map((tag) => tag.name);
return models;
} catch (error) {
const logMessage =
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
logAxiosError({ message: logMessage, error });
return [];
}
const ollamaEndpoint = deriveBaseURL(baseURL);
const resolvedHeaders = resolveHeaders({
headers: options.headers,
user: options.user,
});
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
headers: resolvedHeaders,
timeout: 5000,
});
const models = response.data.models.map((tag) => tag.name);
return models;
}
/**

View File

@@ -5,6 +5,7 @@ const FormData = require('form-data');
const { ProxyAgent } = require('undici');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, oaiToolkit } = require('@librechat/api');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@@ -348,16 +349,7 @@ Error Message: ${error.message}`);
};
if (process.env.PROXY) {
try {
const url = new URL(process.env.PROXY);
axiosConfig.proxy = {
host: url.hostname.replace(/^\[|\]$/g, ''),
port: url.port ? parseInt(url.port, 10) : undefined,
protocol: url.protocol.replace(':', ''),
};
} catch (error) {
logger.error('Error parsing proxy URL:', error);
}
axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {

View File

@@ -448,7 +448,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
}
if (!availableTools) {
try {
availableTools = await getMCPServerTools(serverName);
availableTools = await getMCPServerTools(safeUser.id, serverName);
} catch (error) {
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
}

View File

@@ -62,25 +62,38 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m;
const modelSpecs = req.config?.modelSpecs?.list;
/** @type {TModelSpec | null} */
let modelSpec = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
}
/** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp);
const userId = req.user?.id; // note: userId cannot be undefined at runtime
if (modelSpec?.mcpServers) {
for (const mcpServer of modelSpec.mcpServers) {
mcpServers.add(mcpServer);
}
}
/** @type {string[]} */
const tools = [];
if (ephemeralAgent?.execute_code === true) {
if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
tools.push(Tools.execute_code);
}
if (ephemeralAgent?.file_search === true) {
if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
tools.push(Tools.file_search);
}
if (ephemeralAgent?.web_search === true) {
if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search);
}
@@ -90,7 +103,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
if (addedServers.has(mcpServer)) {
continue;
}
const serverTools = await getMCPServerTools(mcpServer);
const serverTools = await getMCPServerTools(userId, mcpServer);
if (!serverTools) {
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
addedServers.add(mcpServer);
@@ -122,17 +135,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id
* @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
if (!agent_id) {
return null;
}
if (agent_id === EPHEMERAL_AGENT_ID) {
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
}
const agent = await getAgent({
id: agent_id,

View File

@@ -1931,7 +1931,7 @@ describe('models/Agent', () => {
});
// Mock getMCPServerTools to return tools for each server
getMCPServerTools.mockImplementation(async (server) => {
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
return { tool1_mcp_server1: {} };
} else if (server === 'server2') {
@@ -2125,7 +2125,7 @@ describe('models/Agent', () => {
getCachedTools.mockResolvedValue(availableTools);
// Mock getMCPServerTools to return all tools for server1
getMCPServerTools.mockImplementation(async (server) => {
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
return availableTools; // All 100 tools belong to server1
}
@@ -2674,7 +2674,7 @@ describe('models/Agent', () => {
});
// Mock getMCPServerTools to return only tools matching the server
getMCPServerTools.mockImplementation(async (server) => {
getMCPServerTools.mockImplementation(async (_userId, server) => {
if (server === 'server1') {
// Only return tool that correctly matches server1 format
return { tool_mcp_server1: {} };

View File

@@ -1,4 +1,4 @@
const { matchModelName } = require('@librechat/api');
const { matchModelName, findMatchingPattern } = require('@librechat/api');
const defaultRate = 6;
/**
@@ -6,44 +6,58 @@ const defaultRate = 6;
* source: https://aws.amazon.com/bedrock/pricing/
* */
const bedrockValues = {
// Basic llama2 patterns
// Basic llama2 patterns (base defaults to smallest variant)
llama2: { prompt: 0.75, completion: 1.0 },
'llama-2': { prompt: 0.75, completion: 1.0 },
'llama2-13b': { prompt: 0.75, completion: 1.0 },
'llama2:13b': { prompt: 0.75, completion: 1.0 },
'llama2:70b': { prompt: 1.95, completion: 2.56 },
'llama2-70b': { prompt: 1.95, completion: 2.56 },
// Basic llama3 patterns
// Basic llama3 patterns (base defaults to smallest variant)
llama3: { prompt: 0.3, completion: 0.6 },
'llama-3': { prompt: 0.3, completion: 0.6 },
'llama3-8b': { prompt: 0.3, completion: 0.6 },
'llama3:8b': { prompt: 0.3, completion: 0.6 },
'llama3-70b': { prompt: 2.65, completion: 3.5 },
'llama3:70b': { prompt: 2.65, completion: 3.5 },
// llama3-x-Nb pattern
// llama3-x-Nb pattern (base defaults to smallest variant)
'llama3-1': { prompt: 0.22, completion: 0.22 },
'llama3-1-8b': { prompt: 0.22, completion: 0.22 },
'llama3-1-70b': { prompt: 0.72, completion: 0.72 },
'llama3-1-405b': { prompt: 2.4, completion: 2.4 },
'llama3-2': { prompt: 0.1, completion: 0.1 },
'llama3-2-1b': { prompt: 0.1, completion: 0.1 },
'llama3-2-3b': { prompt: 0.15, completion: 0.15 },
'llama3-2-11b': { prompt: 0.16, completion: 0.16 },
'llama3-2-90b': { prompt: 0.72, completion: 0.72 },
'llama3-3': { prompt: 2.65, completion: 3.5 },
'llama3-3-70b': { prompt: 2.65, completion: 3.5 },
// llama3.x:Nb pattern
// llama3.x:Nb pattern (base defaults to smallest variant)
'llama3.1': { prompt: 0.22, completion: 0.22 },
'llama3.1:8b': { prompt: 0.22, completion: 0.22 },
'llama3.1:70b': { prompt: 0.72, completion: 0.72 },
'llama3.1:405b': { prompt: 2.4, completion: 2.4 },
'llama3.2': { prompt: 0.1, completion: 0.1 },
'llama3.2:1b': { prompt: 0.1, completion: 0.1 },
'llama3.2:3b': { prompt: 0.15, completion: 0.15 },
'llama3.2:11b': { prompt: 0.16, completion: 0.16 },
'llama3.2:90b': { prompt: 0.72, completion: 0.72 },
'llama3.3': { prompt: 2.65, completion: 3.5 },
'llama3.3:70b': { prompt: 2.65, completion: 3.5 },
// llama-3.x-Nb pattern
// llama-3.x-Nb pattern (base defaults to smallest variant)
'llama-3.1': { prompt: 0.22, completion: 0.22 },
'llama-3.1-8b': { prompt: 0.22, completion: 0.22 },
'llama-3.1-70b': { prompt: 0.72, completion: 0.72 },
'llama-3.1-405b': { prompt: 2.4, completion: 2.4 },
'llama-3.2': { prompt: 0.1, completion: 0.1 },
'llama-3.2-1b': { prompt: 0.1, completion: 0.1 },
'llama-3.2-3b': { prompt: 0.15, completion: 0.15 },
'llama-3.2-11b': { prompt: 0.16, completion: 0.16 },
'llama-3.2-90b': { prompt: 0.72, completion: 0.72 },
'llama-3.3': { prompt: 2.65, completion: 3.5 },
'llama-3.3-70b': { prompt: 2.65, completion: 3.5 },
'mistral-7b': { prompt: 0.15, completion: 0.2 },
'mistral-small': { prompt: 0.15, completion: 0.2 },
@@ -52,15 +66,19 @@ const bedrockValues = {
'mistral-large-2407': { prompt: 3.0, completion: 9.0 },
'command-text': { prompt: 1.5, completion: 2.0 },
'command-light': { prompt: 0.3, completion: 0.6 },
'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 },
'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 },
'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 },
'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 },
'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 },
'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 },
'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 },
'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 },
'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 },
// AI21 models
'j2-mid': { prompt: 12.5, completion: 12.5 },
'j2-ultra': { prompt: 18.8, completion: 18.8 },
'jamba-instruct': { prompt: 0.5, completion: 0.7 },
// Amazon Titan models
'titan-text-lite': { prompt: 0.15, completion: 0.2 },
'titan-text-express': { prompt: 0.2, completion: 0.6 },
'titan-text-premier': { prompt: 0.5, completion: 1.5 },
// Amazon Nova models
'nova-micro': { prompt: 0.035, completion: 0.14 },
'nova-lite': { prompt: 0.06, completion: 0.24 },
'nova-pro': { prompt: 0.8, completion: 3.2 },
'nova-premier': { prompt: 2.5, completion: 12.5 },
'deepseek.r1': { prompt: 1.35, completion: 5.4 },
};
@@ -71,100 +89,136 @@ const bedrockValues = {
*/
const tokenValues = Object.assign(
{
// Legacy token size mappings (generic patterns - check LAST)
'8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 },
// Generic fallback patterns (check LAST)
'claude-': { prompt: 0.8, completion: 2.4 },
deepseek: { prompt: 0.28, completion: 0.42 },
command: { prompt: 0.38, completion: 0.38 },
gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
gemini: { prompt: 0.5, completion: 1.5 },
'gpt-oss': { prompt: 0.05, completion: 0.2 },
// Specific model variants (check FIRST - more specific patterns at end)
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
o3: { prompt: 2, completion: 8 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.5': { prompt: 75, completion: 150 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-4o': { prompt: 2.5, completion: 10 },
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-pro': { prompt: 15, completion: 120 },
o1: { prompt: 15, completion: 60 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o3: { prompt: 2, completion: 8 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'claude-instant': { prompt: 0.8, completion: 2.4 },
'claude-2': { prompt: 8, completion: 24 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3.5-sonnet': { prompt: 3, completion: 15 },
'claude-3-7-sonnet': { prompt: 3, completion: 15 },
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-sonnet-4': { prompt: 3, completion: 15 },
'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-opus-4': { prompt: 15, completion: 75 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-instant': { prompt: 0.8, completion: 2.4 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'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-reasoner': { prompt: 0.28, completion: 0.42 },
deepseek: { prompt: 0.28, completion: 0.42 },
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
'deepseek-r1': { prompt: 0.4, completion: 2.0 },
'deepseek-v3': { prompt: 0.2, completion: 0.8 },
'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing)
'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
'gemma-3-27b': { prompt: 0.09, completion: 0.16 },
'gemini-1.5': { prompt: 2.5, completion: 10 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing)
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing)
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
'gemini-1.5': { prompt: 2.5, completion: 10 },
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
gemini: { prompt: 0.5, completion: 1.5 },
'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 },
'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
'grok-2-vision': { prompt: 2.0, completion: 10.0 },
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-beta': { prompt: 5.0, completion: 15.0 },
'grok-vision-beta': { prompt: 5.0, completion: 15.0 },
'grok-2': { prompt: 2.0, completion: 10.0 },
'grok-2-1212': { prompt: 2.0, completion: 10.0 },
'grok-2-latest': { prompt: 2.0, completion: 10.0 },
'grok-2': { prompt: 2.0, completion: 10.0 },
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
'grok-2-vision': { prompt: 2.0, completion: 10.0 },
'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 },
'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
'grok-3': { prompt: 3.0, completion: 15.0 },
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
'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-beta': { prompt: 5.0, completion: 15.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'pixtral-large': { prompt: 2.0, completion: 6.0 },
'mistral-saba': { prompt: 0.2, completion: 0.6 },
codestral: { prompt: 0.3, completion: 0.9 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
'ministral-3b': { prompt: 0.04, completion: 0.04 },
// GPT-OSS models
'gpt-oss': { prompt: 0.05, completion: 0.2 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
'mistral-nemo': { prompt: 0.15, completion: 0.15 },
'mistral-saba': { prompt: 0.2, completion: 0.6 },
'pixtral-large': { prompt: 2.0, completion: 6.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'mixtral-8x22b': { prompt: 0.65, completion: 0.65 },
kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing)
// GPT-OSS models (specific sizes)
'gpt-oss:20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss:120b': { prompt: 0.15, completion: 0.6 },
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
// GLM models (Zhipu AI)
// GLM models (Zhipu AI) - general to specific
glm4: { prompt: 0.1, completion: 0.1 },
'glm-4': { prompt: 0.1, completion: 0.1 },
'glm-4-32b': { prompt: 0.1, completion: 0.1 },
'glm-4.5': { prompt: 0.35, completion: 1.55 },
'glm-4.5v': { prompt: 0.6, completion: 1.8 },
'glm-4.5-air': { prompt: 0.14, completion: 0.86 },
'glm-4.5v': { prompt: 0.6, completion: 1.8 },
'glm-4.6': { prompt: 0.5, completion: 1.75 },
// Qwen models
qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing)
'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern
'qwen-turbo': { prompt: 0.05, completion: 0.2 },
'qwen-plus': { prompt: 0.4, completion: 1.2 },
'qwen-max': { prompt: 1.6, completion: 6.4 },
'qwq-32b': { prompt: 0.15, completion: 0.4 },
// Qwen3 models
qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing)
'qwen3-8b': { prompt: 0.035, completion: 0.138 },
'qwen3-14b': { prompt: 0.05, completion: 0.22 },
'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 },
'qwen3-32b': { prompt: 0.05, completion: 0.2 },
'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 },
// Qwen3 VL (Vision-Language) models
'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 },
'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 },
'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 },
'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 },
// Qwen3 specialized models
'qwen3-max': { prompt: 1.2, completion: 6 },
'qwen3-coder': { prompt: 0.22, completion: 0.95 },
'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 },
'qwen3-coder-plus': { prompt: 1, completion: 5 },
'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 },
'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 },
},
bedrockValues,
);
@@ -195,67 +249,39 @@ const cacheTokenValues = {
* @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found.
*/
const getValueKey = (model, endpoint) => {
if (!model || typeof model !== 'string') {
return undefined;
}
// Use findMatchingPattern directly against tokenValues for efficient lookup
if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) {
const matchedKey = findMatchingPattern(model, tokenValues);
if (matchedKey) {
return matchedKey;
}
}
// Fallback: use matchModelName for edge cases and legacy handling
const modelName = matchModelName(model, endpoint);
if (!modelName) {
return undefined;
}
// Legacy token size mappings and aliases for older models
if (modelName.includes('gpt-3.5-turbo-16k')) {
return '16k';
} else if (modelName.includes('gpt-3.5-turbo-0125')) {
return 'gpt-3.5-turbo-0125';
} else if (modelName.includes('gpt-3.5-turbo-1106')) {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) {
return '4k';
} else if (modelName.includes('o4-mini')) {
return 'o4-mini';
} else if (modelName.includes('o4')) {
return 'o4';
} else if (modelName.includes('o3-mini')) {
return 'o3-mini';
} else if (modelName.includes('o3')) {
return 'o3';
} else if (modelName.includes('o1-preview')) {
return 'o1-preview';
} else if (modelName.includes('o1-mini')) {
return 'o1-mini';
} else if (modelName.includes('o1')) {
return 'o1';
} else if (modelName.includes('gpt-4.5')) {
return 'gpt-4.5';
} else if (modelName.includes('gpt-4.1-nano')) {
return 'gpt-4.1-nano';
} else if (modelName.includes('gpt-4.1-mini')) {
return 'gpt-4.1-mini';
} else if (modelName.includes('gpt-4.1')) {
return 'gpt-4.1';
} else if (modelName.includes('gpt-4o-2024-05-13')) {
return 'gpt-4o-2024-05-13';
} else if (modelName.includes('gpt-5-nano')) {
return 'gpt-5-nano';
} else if (modelName.includes('gpt-5-mini')) {
return 'gpt-5-mini';
} else if (modelName.includes('gpt-5')) {
return 'gpt-5';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {
return 'gpt-4o';
} else if (modelName.includes('gpt-4-vision')) {
return 'gpt-4-1106';
} else if (modelName.includes('gpt-4-1106')) {
return 'gpt-4-1106';
return 'gpt-4-1106'; // Alias for gpt-4-vision
} else if (modelName.includes('gpt-4-0125')) {
return 'gpt-4-1106';
return 'gpt-4-1106'; // Alias for gpt-4-0125
} else if (modelName.includes('gpt-4-turbo')) {
return 'gpt-4-1106';
return 'gpt-4-1106'; // Alias for gpt-4-turbo
} else if (modelName.includes('gpt-4-32k')) {
return '32k';
} else if (modelName.includes('gpt-4')) {
return '8k';
} else if (tokenValues[modelName]) {
return modelName;
}
return undefined;

View File

@@ -1,3 +1,4 @@
const { maxTokensMap } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const {
defaultRate,
@@ -113,6 +114,14 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
});
it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => {
expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@@ -288,6 +297,20 @@ describe('getMultiplier', () => {
);
});
it('should return the correct multiplier for gpt-5-pro', () => {
const valueKey = getValueKey('gpt-5-pro-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-pro'].completion,
);
expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-pro'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-pro'].completion,
);
});
it('should return the correct multiplier for gpt-4o', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@@ -471,6 +494,249 @@ describe('AWS Bedrock Model Tests', () => {
});
});
describe('Amazon Model Tests', () => {
describe('Amazon Nova Models', () => {
it('should return correct pricing for nova-premier', () => {
expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe(
tokenValues['nova-premier'].prompt,
);
expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe(
tokenValues['nova-premier'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-premier'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-premier'].completion,
);
});
it('should return correct pricing for nova-pro', () => {
expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe(
tokenValues['nova-pro'].prompt,
);
expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe(
tokenValues['nova-pro'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-pro'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-pro'].completion,
);
});
it('should return correct pricing for nova-lite', () => {
expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe(
tokenValues['nova-lite'].prompt,
);
expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe(
tokenValues['nova-lite'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-lite'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-lite'].completion,
);
});
it('should return correct pricing for nova-micro', () => {
expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe(
tokenValues['nova-micro'].prompt,
);
expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe(
tokenValues['nova-micro'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-micro'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-micro'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier'];
const fullModels = [
'amazon.nova-micro-v1:0',
'amazon.nova-lite-v1:0',
'amazon.nova-pro-v1:0',
'amazon.nova-premier-v1:0',
];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
describe('Amazon Titan Models', () => {
it('should return correct pricing for titan-text-premier', () => {
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-premier'].prompt,
);
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe(
tokenValues['titan-text-premier'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-premier'].prompt,
);
expect(
getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }),
).toBe(tokenValues['titan-text-premier'].completion);
});
it('should return correct pricing for titan-text-express', () => {
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-express'].prompt,
);
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe(
tokenValues['titan-text-express'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-express'].prompt,
);
expect(
getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }),
).toBe(tokenValues['titan-text-express'].completion);
});
it('should return correct pricing for titan-text-lite', () => {
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-lite'].prompt,
);
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe(
tokenValues['titan-text-lite'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-lite'].prompt,
);
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe(
tokenValues['titan-text-lite'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier'];
const fullModels = [
'amazon.titan-text-lite-v1',
'amazon.titan-text-express-v1',
'amazon.titan-text-premier-v1:0',
];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
});
describe('AI21 Model Tests', () => {
describe('AI21 J2 Models', () => {
it('should return correct pricing for j2-mid', () => {
expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe(
tokenValues['j2-mid'].prompt,
);
expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe(
tokenValues['j2-mid'].completion,
);
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe(
tokenValues['j2-mid'].prompt,
);
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe(
tokenValues['j2-mid'].completion,
);
});
it('should return correct pricing for j2-ultra', () => {
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe(
tokenValues['j2-ultra'].prompt,
);
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe(
tokenValues['j2-ultra'].completion,
);
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe(
tokenValues['j2-ultra'].prompt,
);
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe(
tokenValues['j2-ultra'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['j2-mid', 'j2-ultra'];
const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1'];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
describe('AI21 Jamba Models', () => {
it('should return correct pricing for jamba-instruct', () => {
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe(
tokenValues['jamba-instruct'].prompt,
);
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe(
tokenValues['jamba-instruct'].completion,
);
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['jamba-instruct'].prompt,
);
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe(
tokenValues['jamba-instruct'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' });
const fullPrompt = getMultiplier({
model: 'ai21.jamba-instruct-v1:0',
tokenType: 'prompt',
});
const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' });
const fullCompletion = getMultiplier({
model: 'ai21.jamba-instruct-v1:0',
tokenType: 'completion',
});
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt);
expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion);
});
});
});
describe('Deepseek Model Tests', () => {
const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1'];
@@ -502,6 +768,187 @@ describe('Deepseek Model Tests', () => {
});
});
describe('Qwen3 Model Tests', () => {
describe('Qwen3 Base Models', () => {
it('should return correct pricing for qwen3 base pattern', () => {
expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe(
tokenValues['qwen3'].prompt,
);
expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => {
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3'].prompt,
);
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
it('should return correct pricing for qwen3-8b', () => {
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-8b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-8b'].completion,
);
});
it('should return correct pricing for qwen3-14b', () => {
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-14b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-14b'].completion,
);
});
it('should return correct pricing for qwen3-235b-a22b', () => {
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-235b-a22b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-235b-a22b'].completion,
);
});
it('should handle model name variations with provider prefixes', () => {
const models = [
{ input: 'qwen3', expected: 'qwen3' },
{ input: 'qwen3-4b', expected: 'qwen3' },
{ input: 'qwen3-8b', expected: 'qwen3-8b' },
{ input: 'qwen3-32b', expected: 'qwen3-32b' },
];
models.forEach(({ input, expected }) => {
const withPrefix = `alibaba/${input}`;
expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe(
tokenValues[expected].prompt,
);
expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe(
tokenValues[expected].completion,
);
});
});
});
describe('Qwen3 VL (Vision-Language) Models', () => {
it('should return correct pricing for qwen3-vl-8b-thinking', () => {
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-8b-thinking'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-8b-thinking'].completion,
);
});
it('should return correct pricing for qwen3-vl-8b-instruct', () => {
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-8b-instruct'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-8b-instruct'].completion,
);
});
it('should return correct pricing for qwen3-vl-30b-a3b', () => {
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-30b-a3b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-30b-a3b'].completion,
);
});
it('should return correct pricing for qwen3-vl-235b-a22b', () => {
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-235b-a22b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-235b-a22b'].completion,
);
});
});
describe('Qwen3 Specialized Models', () => {
it('should return correct pricing for qwen3-max', () => {
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-max'].prompt,
);
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe(
tokenValues['qwen3-max'].completion,
);
});
it('should return correct pricing for qwen3-coder', () => {
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder'].completion,
);
});
it('should return correct pricing for qwen3-coder-plus', () => {
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder-plus'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder-plus'].completion,
);
});
it('should return correct pricing for qwen3-coder-flash', () => {
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder-flash'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder-flash'].completion,
);
});
it('should return correct pricing for qwen3-next-80b-a3b', () => {
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-next-80b-a3b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-next-80b-a3b'].completion,
);
});
});
describe('Qwen3 Model Variations', () => {
it('should handle all qwen3 models with provider prefixes', () => {
const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct'];
const prefixes = ['alibaba', 'qwen', 'openrouter'];
models.forEach((model) => {
prefixes.forEach((prefix) => {
const fullModel = `${prefix}/${model}`;
expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe(
tokenValues[model].prompt,
);
expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe(
tokenValues[model].completion,
);
});
});
});
it('should handle qwen3-4b falling back to qwen3 base pattern', () => {
const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview'];
testCases.forEach((model) => {
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
});
});
});
describe('getCacheMultiplier', () => {
it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(
@@ -914,6 +1361,37 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct prompt and completion rates for Claude Haiku 4.5', () => {
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe(
tokenValues['claude-haiku-4-5'].prompt,
);
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe(
tokenValues['claude-haiku-4-5'].completion,
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-haiku-4-5');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-haiku-4-5'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-haiku-4-5'].completion,
);
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',
@@ -991,3 +1469,119 @@ describe('Claude Model Tests', () => {
});
});
});
describe('tokens.ts and tx.js sync validation', () => {
it('should resolve all models in maxTokensMap to pricing via getValueKey', () => {
const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]);
const txKeys = Object.keys(tokenValues);
const unresolved = [];
tokensKeys.forEach((key) => {
// Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k')
if (/^\d+k$/.test(key)) return;
// Skip generic pattern keys (end with '-' or ':')
if (key.endsWith('-') || key.endsWith(':')) return;
// Try to resolve via getValueKey
const resolvedKey = getValueKey(key);
// If it resolves and the resolved key has pricing, success
if (resolvedKey && txKeys.includes(resolvedKey)) return;
// If it resolves to a legacy key (4k, 8k, etc), also OK
if (resolvedKey && /^\d+k$/.test(resolvedKey)) return;
// If we get here, this model can't get pricing - flag it
unresolved.push({
key,
resolvedKey: resolvedKey || 'undefined',
context: maxTokensMap[EModelEndpoint.openAI][key],
});
});
if (unresolved.length > 0) {
console.log('\nModels that cannot resolve to pricing via getValueKey:');
unresolved.forEach(({ key, resolvedKey, context }) => {
console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`);
});
}
expect(unresolved).toEqual([]);
});
it('should not have redundant dated variants with same pricing and context as base model', () => {
const txKeys = Object.keys(tokenValues);
const redundant = [];
txKeys.forEach((key) => {
// Check if this is a dated variant (ends with -YYYY-MM-DD)
if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) {
const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, '');
if (txKeys.includes(baseKey)) {
const variantPricing = tokenValues[key];
const basePricing = tokenValues[baseKey];
const variantContext = maxTokensMap[EModelEndpoint.openAI][key];
const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey];
const samePricing =
variantPricing.prompt === basePricing.prompt &&
variantPricing.completion === basePricing.completion;
const sameContext = variantContext === baseContext;
if (samePricing && sameContext) {
redundant.push({
key,
baseKey,
pricing: `${variantPricing.prompt}/${variantPricing.completion}`,
context: variantContext,
});
}
}
}
});
if (redundant.length > 0) {
console.log('\nRedundant dated variants found (same pricing and context as base):');
redundant.forEach(({ key, baseKey, pricing, context }) => {
console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`);
console.log(` Can be removed - pattern matching will handle it`);
});
}
expect(redundant).toEqual([]);
});
it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => {
const txKeys = Object.keys(tokenValues);
const missingContext = [];
txKeys.forEach((key) => {
// Skip legacy token size mappings (4k, 8k, 16k, 32k)
if (/^\d+k$/.test(key)) return;
// Check if this model has a context window defined
const context = maxTokensMap[EModelEndpoint.openAI][key];
if (!context) {
const pricing = tokenValues[key];
missingContext.push({
key,
pricing: `${pricing.prompt}/${pricing.completion}`,
});
}
});
if (missingContext.length > 0) {
console.log('\nModels with pricing but missing context in tokens.ts:');
missingContext.forEach(({ key, pricing }) => {
console.log(` - '${key}' (pricing: ${pricing})`);
console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`);
});
}
expect(missingContext).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0",
"version": "v0.8.1-rc1",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.85",
"@librechat/agents": "^2.4.90",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

View File

@@ -116,11 +116,15 @@ const refreshController = async (req, res) => {
const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort)
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('Error reconnecting OAuth MCP servers:', err);
});
try {
void getOAuthReconnectionManager()
.reconnectServers(userId)
.catch((err) => {
logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
});
} catch (err) {
logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
}
res.status(200).send({ token, user });
} else if (req?.query?.retry) {

View File

@@ -28,6 +28,7 @@ const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -198,7 +199,7 @@ const updateUserPluginsController = async (req, res) => {
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager(user.id);
const mcpManager = getMCPManager();
if (mcpManager) {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
@@ -295,10 +296,11 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
}
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
const mcpManager = getMCPManager(userId);
const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
if (!mcpManager.getOAuthServers().has(serverName)) {
const serverConfig =
(await mcpServersRegistry.getServerConfig(serverName, userId)) ??
appConfig?.mcpServers?.[serverName];
const oauthServers = await mcpServersRegistry.getOAuthServers();
if (!oauthServers.has(serverName)) {
// this server does not use OAuth, so nothing to do here as well
return;
}

View File

@@ -8,6 +8,7 @@ const {
Tokenizer,
checkAccess,
logAxiosError,
sanitizeTitle,
resolveHeaders,
getBalanceConfig,
memoryInstructions,
@@ -775,6 +776,7 @@ class AgentClient extends BaseClient {
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = {
runName: 'AgentRun',
configurable: {
thread_id: this.conversationId,
last_agent_index: this.agentConfigs?.size ?? 0,
@@ -1233,6 +1235,10 @@ class AgentClient extends BaseClient {
handleLLMEnd,
},
],
configurable: {
thread_id: this.conversationId,
user_id: this.user ?? this.options.req.user?.id,
},
},
});
@@ -1270,7 +1276,7 @@ class AgentClient extends BaseClient {
);
});
return titleResult.title;
return sanitizeTitle(titleResult.title);
} catch (err) {
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
return;

View File

@@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({
}),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBe('Generated Title');
});
it('should sanitize the generated title by removing think blocks', async () => {
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
mockRun.generateTitle.mockResolvedValue({
title: titleWithThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should remove the <think> block and return only the clean title
expect(result).toBe('User Hi Greeting');
expect(result).not.toContain('<think>');
expect(result).not.toContain('</think>');
});
it('should return fallback title when sanitization results in empty string', async () => {
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
mockRun.generateTitle.mockResolvedValue({
title: titleOnlyThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return the fallback title since sanitization would result in empty string
expect(result).toBe('Untitled Conversation');
});
it('should handle errors gracefully and return undefined', async () => {
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));

View File

@@ -10,6 +10,7 @@ const {
getAppConfig,
} = require('~/server/services/Config');
const { getMCPManager } = require('~/config');
const { mcpServersRegistry } = require('@librechat/api');
/**
* Get all MCP tools available to the user
@@ -32,7 +33,7 @@ const getMCPTools = async (req, res) => {
const mcpServers = {};
const cachePromises = configuredServers.map((serverName) =>
getMCPServerTools(serverName).then((tools) => ({ serverName, tools })),
getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })),
);
const cacheResults = await Promise.all(cachePromises);
@@ -52,7 +53,7 @@ const getMCPTools = async (req, res) => {
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({ serverName, serverTools }).catch((err) =>
cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
@@ -65,7 +66,7 @@ const getMCPTools = async (req, res) => {
// Get server config once
const serverConfig = appConfig.mcpConfig[serverName];
const rawServerConfig = mcpManager.getRawConfig(serverName);
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
// Initialize server object with all server-level data
const server = {

View File

@@ -15,6 +15,10 @@ jest.mock('@librechat/api', () => ({
storeTokens: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
mcpServersRegistry: {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
},
}));
jest.mock('@librechat/data-schemas', () => ({
@@ -47,6 +51,7 @@ jest.mock('~/models', () => ({
jest.mock('~/server/services/Config', () => ({
setCachedTools: jest.fn(),
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
loadCustomConfig: jest.fn(),
}));
@@ -114,7 +119,7 @@ describe('MCP Routes', () => {
});
describe('GET /:serverName/oauth/initiate', () => {
const { MCPOAuthHandler } = require('@librechat/api');
const { MCPOAuthHandler, mcpServersRegistry } = require('@librechat/api');
const { getLogStores } = require('~/cache');
it('should initiate OAuth flow successfully', async () => {
@@ -127,13 +132,9 @@ describe('MCP Routes', () => {
}),
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
mcpServersRegistry.getServerConfig.mockResolvedValue({});
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
authorizationUrl: 'https://oauth.example.com/auth',
@@ -287,6 +288,7 @@ describe('MCP Routes', () => {
});
it('should handle OAuth callback successfully', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@@ -306,6 +308,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@@ -320,7 +323,6 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@@ -378,6 +380,7 @@ describe('MCP Routes', () => {
});
it('should handle system-level OAuth completion', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@@ -397,14 +400,10 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
@@ -416,6 +415,7 @@ describe('MCP Routes', () => {
});
it('should handle reconnection failure after OAuth', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@@ -435,12 +435,12 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockMcpManager = {
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@@ -460,6 +460,7 @@ describe('MCP Routes', () => {
});
it('should redirect to error page if token storage fails', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@@ -479,6 +480,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@@ -729,12 +731,14 @@ describe('MCP Routes', () => {
});
describe('POST /:serverName/reinitialize', () => {
const { mcpServersRegistry } = require('@librechat/api');
it('should return 404 when server is not found in configuration', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue(null),
disconnectUserConnection: jest.fn().mockResolvedValue(),
};
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@@ -749,9 +753,6 @@ describe('MCP Routes', () => {
it('should handle OAuth requirement during reinitialize', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {},
}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
mcpConfigs: {},
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
@@ -762,6 +763,9 @@ describe('MCP Routes', () => {
}),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@@ -787,12 +791,12 @@ describe('MCP Routes', () => {
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
mcpConfigs: {},
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@@ -808,11 +812,12 @@ describe('MCP Routes', () => {
it('should return 500 when unexpected error occurs', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockImplementation(() => {
throw new Error('Config loading failed');
}),
disconnectUserConnection: jest.fn(),
};
mcpServersRegistry.getServerConfig.mockImplementation(() => {
throw new Error('Config loading failed');
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).post('/api/mcp/test-server/reinitialize');
@@ -845,11 +850,11 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({ endpoint: 'http://test-server.com' }),
disconnectUserConnection: jest.fn().mockResolvedValue(),
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({ endpoint: 'http://test-server.com' });
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@@ -890,16 +895,16 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
endpoint: 'http://test-server.com',
customUserVars: {
API_KEY: 'some-env-var',
},
}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({
endpoint: 'http://test-server.com',
customUserVars: {
API_KEY: 'some-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@@ -1104,17 +1109,17 @@ describe('MCP Routes', () => {
describe('GET /:serverName/auth-values', () => {
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { mcpServersRegistry } = require('@librechat/api');
it('should return auth value flags for server', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: 'some-env-var',
SECRET_TOKEN: 'another-env-var',
},
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {
API_KEY: 'some-env-var',
SECRET_TOKEN: 'another-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
@@ -1134,10 +1139,9 @@ describe('MCP Routes', () => {
});
it('should return 404 when server is not found in configuration', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue(null),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
@@ -1149,14 +1153,13 @@ describe('MCP Routes', () => {
});
it('should handle errors when checking auth values', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: 'some-env-var',
},
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {
API_KEY: 'some-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
@@ -1173,12 +1176,11 @@ describe('MCP Routes', () => {
});
it('should return 500 when auth values check throws unexpected error', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockImplementation(() => {
throw new Error('Config loading failed');
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockImplementation(() => {
throw new Error('Config loading failed');
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
@@ -1188,12 +1190,11 @@ describe('MCP Routes', () => {
});
it('should handle customUserVars that is not an object', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: 'not-an-object',
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: 'not-an-object',
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
@@ -1220,7 +1221,7 @@ describe('MCP Routes', () => {
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
@@ -1238,6 +1239,7 @@ describe('MCP Routes', () => {
});
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
completeFlow: jest.fn(),
@@ -1248,7 +1250,6 @@ describe('MCP Routes', () => {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@@ -1263,7 +1264,7 @@ describe('MCP Routes', () => {
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
const { getCachedTools } = require('~/server/services/Config');
getCachedTools.mockResolvedValue(null);
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
@@ -1289,6 +1290,7 @@ describe('MCP Routes', () => {
});
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
@@ -1296,7 +1298,6 @@ describe('MCP Routes', () => {
.fn()
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);

View File

@@ -12,6 +12,7 @@ const { getAppConfig } = require('~/server/services/Config/app');
const { getProjectByName } = require('~/models/Project');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const router = express.Router();
const emailLoginEnabled =
@@ -125,7 +126,7 @@ router.get('/', async function (req, res) {
payload.minPasswordLength = minPasswordLength;
}
const getMCPServers = () => {
const getMCPServers = async () => {
try {
if (appConfig?.mcpConfig == null) {
return;
@@ -134,9 +135,8 @@ router.get('/', async function (req, res) {
if (!mcpManager) {
return;
}
const mcpServers = mcpManager.getAllServers();
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
const oauthServers = mcpManager.getOAuthServers();
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
@@ -145,7 +145,7 @@ router.get('/', async function (req, res) {
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: oauthServers?.has(serverName),
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
@@ -154,7 +154,7 @@ router.get('/', async function (req, res) {
}
};
getMCPServers();
await getMCPServers();
const webSearchConfig = appConfig?.webSearch;
if (
webSearchConfig != null &&

View File

@@ -6,6 +6,7 @@ const {
MCPOAuthHandler,
MCPTokenStorage,
getUserMCPAuthMap,
mcpServersRegistry,
} = require('@librechat/api');
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
@@ -61,11 +62,12 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
return res.status(400).json({ error: 'Invalid flow state' });
}
const oauthHeaders = await getOAuthHeaders(serverName, userId);
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
getOAuthHeaders(serverName),
oauthHeaders,
oauthConfig,
);
@@ -133,12 +135,8 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
});
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(
flowId,
code,
flowManager,
getOAuthHeaders(serverName),
);
const oauthHeaders = await getOAuthHeaders(serverName, flowState.userId);
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager, oauthHeaders);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
/** Persist tokens immediately so reconnection uses fresh credentials */
@@ -205,6 +203,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const tools = await userConnection.fetchTools();
await updateMCPServerTools({
userId: flowState.userId,
serverName,
tools,
});
@@ -355,7 +354,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
const mcpManager = getMCPManager();
const serverConfig = mcpManager.getRawConfig(serverName);
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
@@ -504,8 +503,7 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
return res.status(401).json({ error: 'User not authenticated' });
}
const mcpManager = getMCPManager();
const serverConfig = mcpManager.getRawConfig(serverName);
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, user.id);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
@@ -544,9 +542,8 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
}
});
function getOAuthHeaders(serverName) {
const mcpManager = getMCPManager();
const serverConfig = mcpManager.getRawConfig(serverName);
async function getOAuthHeaders(serverName, userId) {
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
return serverConfig?.oauth_headers ?? {};
}

View File

@@ -0,0 +1,10 @@
const { ToolCacheKeys } = require('../getCachedTools');
describe('getCachedTools - Cache Isolation Security', () => {
describe('ToolCacheKeys.MCP_SERVER', () => {
it('should generate cache keys that include userId', () => {
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
expect(key).toBe('tools:mcp:user123:github');
});
});
});

View File

@@ -7,24 +7,25 @@ const getLogStores = require('~/cache/getLogStores');
const ToolCacheKeys = {
/** Global tools available to all users */
GLOBAL: 'tools:global',
/** MCP tools cached by server name */
MCP_SERVER: (serverName) => `tools:mcp:${serverName}`,
/** MCP tools cached by user ID and server name */
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
};
/**
* Retrieves available tools from cache
* @function getCachedTools
* @param {Object} options - Options for retrieving tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name to get cached tools for
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { serverName } = options;
const { userId, serverName } = options;
// Return MCP server-specific tools if requested
if (serverName) {
return await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
if (serverName && userId) {
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
}
// Default to global tools
@@ -36,17 +37,18 @@ async function getCachedTools(options = {}) {
* @function setCachedTools
* @param {Object} tools - The tools object to cache
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name for server-specific tools
* @param {number} [options.ttl] - Time to live in milliseconds
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { serverName, ttl } = options;
const { userId, serverName, ttl } = options;
// Cache by MCP server if specified
if (serverName) {
return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl);
// Cache by MCP server if specified (requires userId)
if (serverName && userId) {
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
}
// Default to global cache
@@ -57,13 +59,14 @@ async function setCachedTools(tools, options = {}) {
* Invalidates cached tools
* @function invalidateCachedTools
* @param {Object} options - Options for invalidating tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name to invalidate
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const { serverName, invalidateGlobal = false } = options;
const { userId, serverName, invalidateGlobal = false } = options;
const keysToDelete = [];
@@ -71,22 +74,23 @@ async function invalidateCachedTools(options = {}) {
keysToDelete.push(ToolCacheKeys.GLOBAL);
}
if (serverName) {
keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName));
if (serverName && userId) {
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
}
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
/**
* Gets MCP tools for a specific server from cache or merges with global tools
* Gets MCP tools for a specific server from cache
* @function getMCPServerTools
* @param {string} userId - The user ID
* @param {string} serverName - The MCP server name
* @returns {Promise<LCAvailableTools|null>} The available tools for the server
*/
async function getMCPServerTools(serverName) {
async function getMCPServerTools(userId, serverName) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName));
const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
if (serverTools) {
return serverTools;

View File

@@ -57,7 +57,7 @@ async function loadConfigModels(req) {
for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i];
const { models, name: configName, baseURL, apiKey } = endpoint;
const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
const name = normalizeEndpointName(configName);
endpointsMap[name] = endpoint;
@@ -76,6 +76,8 @@ async function loadConfigModels(req) {
apiKey: API_KEY,
baseURL: BASE_URL,
user: req.user.id,
userObject: req.user,
headers: endpointHeaders,
direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery,
});

View File

@@ -6,11 +6,12 @@ const { getLogStores } = require('~/cache');
/**
* Updates MCP tools in the cache for a specific server
* @param {Object} params - Parameters for updating MCP tools
* @param {string} params.userId - User ID for user-specific caching
* @param {string} params.serverName - MCP server name
* @param {Array} params.tools - Array of tool objects from MCP server
* @returns {Promise<LCAvailableTools>}
*/
async function updateMCPServerTools({ serverName, tools }) {
async function updateMCPServerTools({ userId, serverName, tools }) {
try {
const serverTools = {};
const mcpDelimiter = Constants.mcp_delimiter;
@@ -27,14 +28,16 @@ async function updateMCPServerTools({ serverName, tools }) {
};
}
await setCachedTools(serverTools, { serverName });
await setCachedTools(serverTools, { userId, serverName });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`);
logger.debug(
`[MCP Cache] Updated ${tools.length} tools for server ${serverName} (user: ${userId})`,
);
return serverTools;
} catch (error) {
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
logger.error(`[MCP Cache] Failed to update tools for ${serverName} (user: ${userId}):`, error);
throw error;
}
}
@@ -65,21 +68,22 @@ async function mergeAppTools(appTools) {
/**
* Caches MCP server tools (no longer merges with global)
* @param {object} params
* @param {string} params.userId - User ID for user-specific caching
* @param {string} params.serverName
* @param {import('@librechat/api').LCAvailableTools} params.serverTools
* @returns {Promise<void>}
*/
async function cacheMCPServerTools({ serverName, serverTools }) {
async function cacheMCPServerTools({ userId, serverName, serverTools }) {
try {
const count = Object.keys(serverTools).length;
if (!count) {
return;
}
// Only cache server-specific tools, no merging with global
await setCachedTools(serverTools, { serverName });
logger.debug(`Cached ${count} MCP server tools for ${serverName}`);
await setCachedTools(serverTools, { userId, serverName });
logger.debug(`Cached ${count} MCP server tools for ${serverName} (user: ${userId})`);
} catch (error) {
logger.error(`Failed to cache MCP server tools for ${serverName}:`, error);
logger.error(`Failed to cache MCP server tools for ${serverName} (user: ${userId}):`, error);
throw error;
}
}

View File

@@ -134,16 +134,16 @@ const initializeAgent = async ({
});
const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
const maxTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens,
modelOptions.maxTokens,
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
const maxOutputTokens = optionalChainWithEmptyCheck(
options.llmConfig?.maxOutputTokens,
options.llmConfig?.maxTokens,
0,
);
const agentMaxContextTokens = optionalChainWithEmptyCheck(
maxContextTokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
4096,
18000,
);
if (
@@ -203,7 +203,7 @@ const initializeAgent = async ({
userMCPAuthMap,
toolContextMap,
useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
};
};

View File

@@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
const { loadAgent } = require('~/models/Agent');
const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody;
const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
const agentPromise = loadAgent({
req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint,
model_parameters,
@@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint,
agent_id,
endpointType,
instructions,
model_parameters,
agent: agentPromise,
});

View File

@@ -1,4 +1,3 @@
const { Providers } = require('@librechat/agents');
const {
resolveHeaders,
isUserProvided,
@@ -143,39 +142,27 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
if (optionsOnly) {
const modelOptions = endpointOption?.model_parameters ?? {};
if (endpoint !== Providers.OLLAMA) {
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
clientOptions = Object.assign(
{
modelOptions,
},
clientOptions,
);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null) {
options.useLegacyContent = true;
options.endpointTokenConfig = endpointTokenConfig;
}
if (!clientOptions.streamRate) {
return options;
}
if (clientOptions.reverseProxyUrl) {
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0];
delete clientOptions.reverseProxyUrl;
}
return {
useLegacyContent: true,
llmConfig: modelOptions,
};
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
return options;
}
const client = new OpenAIClient(apiKey, clientOptions);

View File

@@ -143,7 +143,7 @@ const initializeClient = async ({
modelOptions.model = modelName;
clientOptions = Object.assign({ modelOptions }, clientOptions);
clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions);
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null && serverless === true) {
options.useLegacyContent = true;
}

View File

@@ -227,7 +227,6 @@ class STTService {
}
const headers = {
'Content-Type': 'multipart/form-data',
...(apiKey && { 'api-key': apiKey }),
};

View File

@@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) {
settings.advancedMode = speechTab.advancedMode;
}
if (speechTab.speechToText) {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
if (speechTab.speechToText !== undefined) {
if (typeof speechTab.speechToText === 'boolean') {
settings.speechToText = speechTab.speechToText;
} else {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
}
}
}
}
if (speechTab.textToSpeech) {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
if (speechTab.textToSpeech !== undefined) {
if (typeof speechTab.textToSpeech === 'boolean') {
settings.textToSpeech = speechTab.textToSpeech;
} else {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
}
}
}
}

View File

@@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
/**
* Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of
* Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
@@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
const getUserEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const response = await graphClient
.api('/me/getMemberGroups')
.post({ securityEnabledOnly: false });
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
const groupIds = Array.isArray(response?.value) ? response.value : [];
return [...new Set(groupIds.map((groupId) => String(groupId)))];
} catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return [];
@@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => {
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allGroupIds = [];
let nextLink = '/me/ownedObjects/microsoft.graph.group';
const groupsResponse = await graphClient
.api('/me/ownedObjects/microsoft.graph.group')
.select('id')
.get();
while (nextLink) {
const response = await graphClient.api(nextLink).select('id').top(999).get();
const groups = response?.value || [];
allGroupIds.push(...groups.map((group) => group.id));
return (groupsResponse.value || []).map((group) => group.id);
nextLink = response['@odata.nextLink']
? response['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allGroupIds;
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
@@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
const allMembers = new Set();
let nextLink = `/groups/${groupId}/transitiveMembers`;
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
const members = membersResponse?.value || [];
members.forEach((member) => {
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
allMembers.add(member.id);
}
});
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
? membersResponse['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allMembers;
return Array.from(allMembers);
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];

View File

@@ -73,6 +73,7 @@ describe('GraphApiService', () => {
header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(),
get: jest.fn(),
post: jest.fn(),
};
Client.init.mockReturnValue(mockGraphClient);
@@ -514,31 +515,33 @@ describe('GraphApiService', () => {
});
describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => {
it('should fetch user groups using getMemberGroups endpoint', async () => {
const mockGroupsResponse = {
value: [
{
id: 'group-1',
},
{
id: 'group-2',
},
],
value: ['group-1', 'group-2'],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
expect(result).toEqual(['group-1', 'group-2']);
});
it('should deduplicate returned group ids', async () => {
mockGraphClient.post.mockResolvedValue({
value: ['group-1', 'group-2', 'group-1'],
});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
mockGraphClient.post.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -550,7 +553,7 @@ describe('GraphApiService', () => {
value: [],
};
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -558,7 +561,7 @@ describe('GraphApiService', () => {
});
it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({});
mockGraphClient.post.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
@@ -566,6 +569,89 @@ describe('GraphApiService', () => {
});
});
describe('getUserOwnedEntraGroups', () => {
it('should fetch owned groups with pagination support', async () => {
const firstPage = {
value: [
{
id: 'owned-group-1',
},
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
};
const secondPage = {
value: [
{
id: 'owned-group-2',
},
],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
1,
'/me/ownedObjects/microsoft.graph.group',
);
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('getGroupMembers', () => {
it('should fetch transitive members and include only users', async () => {
const firstPage = {
value: [
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
};
const secondPage = {
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/groups/group-id/transitiveMembers?$skiptoken=abc',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(result).toEqual(['user-1', 'user-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -25,6 +25,7 @@ const { findToken, createToken, updateToken } = require('~/models');
const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
/**
* @param {object} params
@@ -450,7 +451,7 @@ async function getMCPSetupData(userId) {
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
}
const userConnections = mcpManager.getUserConnections(userId) || new Map();
const oauthServers = mcpManager.getOAuthServers();
const oauthServers = await mcpServersRegistry.getOAuthServers();
return {
mcpConfig,

View File

@@ -50,6 +50,9 @@ jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
normalizeServerName: jest.fn((name) => name),
convertWithResolvedRefs: jest.fn((params) => params),
mcpServersRegistry: {
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
},
}));
jest.mock('librechat-data-provider', () => ({
@@ -100,6 +103,7 @@ describe('tests for the new helper functions used by the MCP connection status e
let mockGetFlowStateManager;
let mockGetLogStores;
let mockGetOAuthReconnectionManager;
let mockMcpServersRegistry;
beforeEach(() => {
jest.clearAllMocks();
@@ -108,6 +112,7 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetFlowStateManager = require('~/config').getFlowStateManager;
mockGetLogStores = require('~/cache').getLogStores;
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
mockMcpServersRegistry = require('@librechat/api').mcpServersRegistry;
});
describe('getMCPSetupData', () => {
@@ -125,8 +130,8 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetMCPManager.mockReturnValue({
appConnections: { getAll: jest.fn(() => new Map()) },
getUserConnections: jest.fn(() => new Map()),
getOAuthServers: jest.fn(() => new Set()),
});
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
});
it('should successfully return MCP setup data', async () => {
@@ -139,9 +144,9 @@ describe('tests for the new helper functions used by the MCP connection status e
const mockMCPManager = {
appConnections: { getAll: jest.fn(() => mockAppConnections) },
getUserConnections: jest.fn(() => mockUserConnections),
getOAuthServers: jest.fn(() => mockOAuthServers),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(mockOAuthServers);
const result = await getMCPSetupData(mockUserId);
@@ -149,7 +154,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
expect(mockMCPManager.getOAuthServers).toHaveBeenCalled();
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled();
expect(result).toEqual({
mcpConfig: mockConfig.mcpServers,
@@ -170,9 +175,9 @@ describe('tests for the new helper functions used by the MCP connection status e
const mockMCPManager = {
appConnections: { getAll: jest.fn(() => null) },
getUserConnections: jest.fn(() => null),
getOAuthServers: jest.fn(() => new Set()),
};
mockGetMCPManager.mockReturnValue(mockMCPManager);
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
const result = await getMCPSetupData(mockUserId);

View File

@@ -39,6 +39,8 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
* @async
*/
@@ -52,6 +54,8 @@ const fetchModels = async ({
userIdQuery = false,
createTokenConfig = true,
tokenKey,
headers,
userObject,
}) => {
let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
@@ -65,7 +69,13 @@ const fetchModels = async ({
}
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
return await OllamaClient.fetchModels(baseURL);
try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) {
const logMessage =
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
logAxiosError({ message: logMessage, error: ollamaError });
}
}
try {

View File

@@ -1,5 +1,5 @@
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, resolveHeaders } = require('@librechat/api');
const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
const {
@@ -18,6 +18,8 @@ jest.mock('@librechat/api', () => {
processModelData: jest.fn((...args) => {
return originalUtils.processModelData(...args);
}),
logAxiosError: jest.fn(),
resolveHeaders: jest.fn((options) => options?.headers || {}),
};
});
@@ -277,12 +279,51 @@ describe('fetchModels with Ollama specific logic', () => {
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: {},
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails', async () => {
axios.get.mockRejectedValue(new Error('Network error'));
it('should pass headers and user object to Ollama fetchModels', async () => {
const customHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer custom-token',
};
const userObject = {
id: 'user789',
email: 'test@example.com',
};
resolveHeaders.mockReturnValueOnce(customHeaders);
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
baseURL: 'https://api.ollama.test.com',
name: 'ollama',
headers: customHeaders,
userObject,
});
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: customHeaders,
user: userObject,
});
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: customHeaders,
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
axios.get.mockResolvedValueOnce({
data: {
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
},
});
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
@@ -290,8 +331,13 @@ describe('fetchModels with Ollama specific logic', () => {
name: 'OllamaAPI',
});
expect(models).toEqual([]);
expect(logger.error).toHaveBeenCalled();
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
expect(logAxiosError).toHaveBeenCalledWith({
message:
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
error: expect.any(Error),
});
expect(axios.get).toHaveBeenCalledTimes(2);
});
it('should return an empty array if no baseURL is provided', async () => {

View File

@@ -98,6 +98,7 @@ async function reinitMCPServer({
if (connection && !oauthRequired) {
tools = await connection.fetchTools();
availableTools = await updateMCPServerTools({
userId: user.id,
serverName,
tools,
});

View File

@@ -15,7 +15,7 @@ async function initializeMCPs() {
const mcpManager = await createMCPManager(mcpServers);
try {
const mcpTools = mcpManager.getAppToolFunctions() || {};
const mcpTools = (await mcpManager.getAppToolFunctions()) || {};
await mergeAppTools(mcpTools);
logger.info(

View File

@@ -304,6 +304,7 @@ describe('Apple Login Strategy', () => {
fileStrategy: 'local',
balance: { enabled: false },
}),
'jane.doe@example.com',
);
});

View File

@@ -357,16 +357,18 @@ async function setupOpenId() {
};
const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const result = await findOpenIDUser({
findUser,
email: claims.email,
email: email,
openidId: claims.sub,
idOnTheSource: claims.oid,
strategyName: 'openidStrategy',
@@ -433,7 +435,7 @@ async function setupOpenId() {
provider: 'openid',
openidId: userinfo.sub,
username,
email: userinfo.email || '',
email: email || '',
emailVerified: userinfo.email_verified || false,
name: fullName,
idOnTheSource: userinfo.oid,
@@ -447,8 +449,8 @@ async function setupOpenId() {
user.username = username;
user.name = fullName;
user.idOnTheSource = userinfo.oid;
if (userinfo.email && userinfo.email !== user.email) {
user.email = userinfo.email;
if (email && email !== user.email) {
user.email = email;
user.emailVerified = userinfo.email_verified || false;
}
}

View File

@@ -5,22 +5,25 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { updateUser, createUser, getUserById } = require('~/models');
/**
* Updates the avatar URL of an existing user. If the user's avatar URL does not include the query parameter
* Updates the avatar URL and email of an existing user. If the user's avatar URL does not include the query parameter
* '?manual=true', it updates the user's avatar with the provided URL. For local file storage, it directly updates
* the avatar URL, while for other storage types, it processes the avatar URL using the specified file strategy.
* Also updates the email if it has changed (e.g., when a Google Workspace email is updated).
*
* @param {IUser} oldUser - The existing user object that needs to be updated.
* @param {string} avatarUrl - The new avatar URL to be set for the user.
* @param {AppConfig} appConfig - The application configuration object.
* @param {string} [email] - Optional. The new email address to update if it has changed.
*
* @returns {Promise<void>}
* The function updates the user's avatar and saves the user object. It does not return any value.
* The function updates the user's avatar and/or email and saves the user object. It does not return any value.
*
* @throws {Error} Throws an error if there's an issue saving the updated user object.
*/
const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
const handleExistingUser = async (oldUser, avatarUrl, appConfig, email) => {
const fileStrategy = appConfig?.fileStrategy ?? process.env.CDN_PROVIDER;
const isLocal = fileStrategy === FileSources.local;
const updates = {};
let updatedAvatar = false;
const hasManualFlag =
@@ -39,7 +42,16 @@ const handleExistingUser = async (oldUser, avatarUrl, appConfig) => {
}
if (updatedAvatar) {
await updateUser(oldUser._id, { avatar: updatedAvatar });
updates.avatar = updatedAvatar;
}
/** Update email if it has changed */
if (email && email.trim() !== oldUser.email) {
updates.email = email.trim();
}
if (Object.keys(updates).length > 0) {
await updateUser(oldUser._id, updates);
}
};

View File

@@ -167,4 +167,76 @@ describe('handleExistingUser', () => {
// This should throw an error when trying to access oldUser._id
await expect(handleExistingUser(null, avatarUrl)).rejects.toThrow();
});
it('should update email when it has changed', async () => {
const oldUser = {
_id: 'user123',
email: 'old@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const newEmail = 'new@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'new@example.com' });
});
it('should update both avatar and email when both have changed', async () => {
const oldUser = {
_id: 'user123',
email: 'old@example.com',
avatar: null,
};
const avatarUrl = 'https://example.com/new-avatar.png';
const newEmail = 'new@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, newEmail);
expect(updateUser).toHaveBeenCalledWith('user123', {
avatar: avatarUrl,
email: 'new@example.com',
});
});
it('should not update email when it has not changed', async () => {
const oldUser = {
_id: 'user123',
email: 'same@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const sameEmail = 'same@example.com';
await handleExistingUser(oldUser, avatarUrl, {}, sameEmail);
expect(updateUser).not.toHaveBeenCalled();
});
it('should trim email before comparison and update', async () => {
const oldUser = {
_id: 'user123',
email: 'test@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
const newEmailWithSpaces = ' newemail@example.com ';
await handleExistingUser(oldUser, avatarUrl, {}, newEmailWithSpaces);
expect(updateUser).toHaveBeenCalledWith('user123', { email: 'newemail@example.com' });
});
it('should not update when email parameter is not provided', async () => {
const oldUser = {
_id: 'user123',
email: 'test@example.com',
avatar: 'https://example.com/avatar.png?manual=true',
};
const avatarUrl = 'https://example.com/avatar.png';
await handleExistingUser(oldUser, avatarUrl, {});
expect(updateUser).not.toHaveBeenCalled();
});
});

View File

@@ -25,10 +25,24 @@ const socialLogin =
return cb(error);
}
const existingUser = await findUser({ email: email.trim() });
const providerKey = `${provider}Id`;
let existingUser = null;
/** First try to find user by provider ID (e.g., googleId, facebookId) */
if (id && typeof id === 'string') {
existingUser = await findUser({ [providerKey]: id });
}
/** If not found by provider ID, try finding by email */
if (!existingUser) {
existingUser = await findUser({ email: email?.trim() });
if (existingUser) {
logger.warn(`[${provider}Login] User found by email: ${email} but not by ${providerKey}`);
}
}
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl, appConfig);
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
return cb(null, existingUser);
} else if (existingUser) {
logger.info(

View File

@@ -0,0 +1,276 @@
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
return {
...actualModule,
logger: {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
};
});
jest.mock('./process', () => ({
createSocialUser: jest.fn(),
handleExistingUser: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn().mockReturnValue(true),
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
describe('socialLogin', () => {
const mockGetProfileDetails = ({ profile }) => ({
email: profile.emails[0].value,
id: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
username: profile.name?.givenName || 'user',
name: `${profile.name?.givenName || ''} ${profile.name?.familyName || ''}`.trim(),
emailVerified: profile.emails[0].verified || false,
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Finding users by provider ID', () => {
it('should find user by provider ID (googleId) when email has changed', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const oldEmail = 'old@example.com';
const newEmail = 'new@example.com';
const existingUser = {
_id: 'user123',
email: oldEmail,
provider: 'google',
googleId: googleId,
};
/** Mock findUser to return user on first call (by googleId), null on second call */
findUser
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
const mockProfile = {
id: googleId,
emails: [{ value: newEmail, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by googleId first */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Verify it did NOT search by email (because it found user by googleId) */
expect(findUser).toHaveBeenCalledTimes(1);
/** Verify handleExistingUser was called with the new email */
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/avatar.png',
expect.any(Object),
newEmail,
);
/** Verify callback was called with success */
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should find user by provider ID (facebookId) when using Facebook', async () => {
const provider = 'facebook';
const facebookId = 'fb-user-456';
const email = 'user@example.com';
const existingUser = {
_id: 'user456',
email: email,
provider: 'facebook',
facebookId: facebookId,
};
findUser.mockResolvedValue(existingUser); // Always returns user
const mockProfile = {
id: facebookId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/fb-avatar.png' }],
name: { givenName: 'Jane', familyName: 'Smith' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by facebookId first */
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/fb-avatar.png',
expect.any(Object),
email,
);
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should fallback to finding user by email if not found by provider ID', async () => {
const provider = 'google';
const googleId = 'google-user-789';
const email = 'user@example.com';
const existingUser = {
_id: 'user789',
email: email,
provider: 'google',
googleId: 'old-google-id', // Different googleId (edge case)
};
/** First call (by googleId) returns null, second call (by email) returns user */
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Bob', familyName: 'Johnson' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify warning log */
expect(logger.warn).toHaveBeenCalledWith(
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
);
expect(handleExistingUser).toHaveBeenCalled();
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
it('should create new user if not found by provider ID or email', async () => {
const provider = 'google';
const googleId = 'google-new-user';
const email = 'newuser@example.com';
const newUser = {
_id: 'newuser123',
email: email,
provider: 'google',
googleId: googleId,
};
/** Both searches return null */
findUser.mockResolvedValue(null);
createSocialUser.mockResolvedValue(newUser);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'New', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify createSocialUser was called */
expect(createSocialUser).toHaveBeenCalledWith({
email: email,
avatarUrl: 'https://example.com/avatar.png',
provider: provider,
providerKey: 'googleId',
providerId: googleId,
username: 'New',
name: 'New User',
emailVerified: true,
appConfig: expect.any(Object),
});
expect(callback).toHaveBeenCalledWith(null, newUser);
});
});
describe('Error handling', () => {
it('should return error if user exists with different provider', async () => {
const provider = 'google';
const googleId = 'google-user-123';
const email = 'user@example.com';
const existingUser = {
_id: 'user123',
email: email,
provider: 'local', // Different provider
};
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'John', familyName: 'Doe' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
/** Verify error callback */
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
code: ErrorTypes.AUTH_FAILED,
provider: 'local',
}),
);
expect(logger.info).toHaveBeenCalledWith(
`[${provider}Login] User ${email} already exists with provider local`,
);
});
});
});

View File

@@ -186,6 +186,19 @@ describe('getModelMaxTokens', () => {
);
});
test('should return correct tokens for gpt-5-pro matches', () => {
expect(getModelMaxTokens('gpt-5-pro')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro']);
expect(getModelMaxTokens('gpt-5-pro-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
expect(getModelMaxTokens('openai/gpt-5-pro')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
expect(getModelMaxTokens('gpt-5-pro-2025-01-30')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
});
test('should return correct tokens for Anthropic models', () => {
const models = [
'claude-2.1',
@@ -469,7 +482,7 @@ describe('getModelMaxTokens', () => {
test('should return correct max output tokens for GPT-5 models', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => {
['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model],
@@ -582,6 +595,13 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
});
it('should return the closest matching key for gpt-5-pro matches', () => {
expect(matchModelName('openai/gpt-5-pro')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-preview')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
});
// Tests for Google models
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
@@ -832,6 +852,49 @@ describe('Claude Model Tests', () => {
);
});
it('should return correct context length for Claude Haiku 4.5', () => {
expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
expect(getModelMaxTokens('claude-haiku-4-5')).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
expect(modelKey).toBe('claude-haiku-4-5');
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
});
});
it('should match model names correctly for Claude Haiku 4.5', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-haiku-4-5');
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [
'claude-sonnet-4',
@@ -924,6 +987,121 @@ describe('Kimi Model Tests', () => {
});
});
describe('Qwen3 Model Tests', () => {
describe('getModelMaxTokens', () => {
test('should return correct tokens for Qwen3 base pattern', () => {
expect(getModelMaxTokens('qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
});
test('should return correct tokens for qwen3-4b (falls back to qwen3)', () => {
expect(getModelMaxTokens('qwen3-4b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
});
test('should return correct tokens for Qwen3 base models', () => {
expect(getModelMaxTokens('qwen3-8b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-8b']);
expect(getModelMaxTokens('qwen3-14b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-14b']);
expect(getModelMaxTokens('qwen3-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-32b']);
expect(getModelMaxTokens('qwen3-235b-a22b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-235b-a22b'],
);
});
test('should return correct tokens for Qwen3 VL (Vision-Language) models', () => {
expect(getModelMaxTokens('qwen3-vl-8b-thinking')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-thinking'],
);
expect(getModelMaxTokens('qwen3-vl-8b-instruct')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
);
expect(getModelMaxTokens('qwen3-vl-30b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-30b-a3b'],
);
expect(getModelMaxTokens('qwen3-vl-235b-a22b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-235b-a22b'],
);
});
test('should return correct tokens for Qwen3 specialized models', () => {
expect(getModelMaxTokens('qwen3-max')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-max']);
expect(getModelMaxTokens('qwen3-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
);
expect(getModelMaxTokens('qwen3-coder-30b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-30b-a3b'],
);
expect(getModelMaxTokens('qwen3-coder-plus')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-plus'],
);
expect(getModelMaxTokens('qwen3-coder-flash')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-flash'],
);
expect(getModelMaxTokens('qwen3-next-80b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-next-80b-a3b'],
);
});
test('should handle Qwen3 models with provider prefixes', () => {
expect(getModelMaxTokens('alibaba/qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
expect(getModelMaxTokens('alibaba/qwen3-4b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
);
expect(getModelMaxTokens('qwen/qwen3-8b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
);
expect(getModelMaxTokens('openrouter/qwen3-max')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
);
expect(getModelMaxTokens('alibaba/qwen3-vl-8b-instruct')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
);
expect(getModelMaxTokens('qwen/qwen3-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
);
});
test('should handle Qwen3 models with suffixes', () => {
expect(getModelMaxTokens('qwen3-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
expect(getModelMaxTokens('qwen3-4b-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
);
expect(getModelMaxTokens('qwen3-8b-latest')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
);
expect(getModelMaxTokens('qwen3-max-2024')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
);
});
});
describe('matchModelName', () => {
test('should match exact Qwen3 model names', () => {
expect(matchModelName('qwen3')).toBe('qwen3');
expect(matchModelName('qwen3-4b')).toBe('qwen3');
expect(matchModelName('qwen3-8b')).toBe('qwen3-8b');
expect(matchModelName('qwen3-vl-8b-thinking')).toBe('qwen3-vl-8b-thinking');
expect(matchModelName('qwen3-max')).toBe('qwen3-max');
expect(matchModelName('qwen3-coder')).toBe('qwen3-coder');
});
test('should match Qwen3 model variations with provider prefixes', () => {
expect(matchModelName('alibaba/qwen3')).toBe('qwen3');
expect(matchModelName('alibaba/qwen3-4b')).toBe('qwen3');
expect(matchModelName('qwen/qwen3-8b')).toBe('qwen3-8b');
expect(matchModelName('openrouter/qwen3-max')).toBe('qwen3-max');
expect(matchModelName('alibaba/qwen3-vl-8b-instruct')).toBe('qwen3-vl-8b-instruct');
expect(matchModelName('qwen/qwen3-coder')).toBe('qwen3-coder');
});
test('should match Qwen3 model variations with suffixes', () => {
expect(matchModelName('qwen3-preview')).toBe('qwen3');
expect(matchModelName('qwen3-4b-preview')).toBe('qwen3');
expect(matchModelName('qwen3-8b-latest')).toBe('qwen3-8b');
expect(matchModelName('qwen3-max-2024')).toBe('qwen3-max');
expect(matchModelName('qwen3-coder-v1')).toBe('qwen3-coder');
});
});
});
describe('GLM Model Tests (Zhipu AI)', () => {
describe('getModelMaxTokens', () => {
test('should return correct tokens for GLM models', () => {

View File

@@ -1,4 +1,4 @@
/** v0.8.0 */
/** v0.8.1-rc1 */
module.exports = {
roots: ['<rootDir>/src'],
testEnvironment: 'jsdom',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0",
"version": "v0.8.1-rc1",
"description": "",
"type": "module",
"scripts": {
@@ -149,7 +149,7 @@
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.3.6",
"vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2"

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom';
@@ -8,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
import { router } from './routes';
@@ -24,6 +26,10 @@ const App = () => {
}),
});
useEffect(() => {
initializeFontSize();
}, []);
// Load theme from environment variables if available
const envTheme = getThemeFromEnv();

View File

@@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !isEphemeralAgent(agent_id),
});
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
enabled: !isEphemeralAgent(agent_id),
});
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const { data: mcpData } = useMCPToolsQuery({
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,

View File

@@ -11,9 +11,9 @@ import {
AgentListResponse,
} from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useLocalize, useDefaultConvo } from '~/hooks';
import { useChatContext } from '~/Providers';
import { renderAgentAvatar } from '~/utils';
interface SupportContact {
name?: string;
@@ -56,10 +56,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
/** Template with agent configuration */

View File

@@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
@@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus';
import { cn, clearMessagesCache } from '~/utils';
import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import { cn } from '~/utils';
import store from '~/store';
interface AgentMarketplaceProps {
@@ -224,10 +224,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<t.TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};

View File

@@ -58,6 +58,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>

View File

@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
// Performance check: rendering should be fast
const renderTime = endTime - startTime;
expect(renderTime).toBeLessThan(720);
expect(renderTime).toBeLessThan(740);
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);

View File

@@ -1,89 +0,0 @@
import { useState, useMemo, memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Atom, ChevronDown } from 'lucide-react';
import type { MouseEvent, FC } from 'react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const BUTTON_STYLES = {
base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear',
icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200',
} as const;
const CONTENT_STYLES = {
wrapper: 'relative pl-3 text-text-secondary',
border:
'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy',
partBorder:
'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy',
text: 'whitespace-pre-wrap leading-[26px]',
} as const;
export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo(
({ isPart, children }) => (
<div className={CONTENT_STYLES.wrapper}>
<div className={isPart === true ? CONTENT_STYLES.partBorder : CONTENT_STYLES.border} />
<p className={CONTENT_STYLES.text}>{children}</p>
</div>
),
);
export const ThinkingButton = memo(
({
isExpanded,
onClick,
label,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string;
}) => (
<button type="button" onClick={onClick} className={BUTTON_STYLES.base}>
<Atom size={14} className="mr-1.5 text-text-secondary" />
{label}
<ChevronDown className={`${BUTTON_STYLES.icon} ${isExpanded ? 'rotate-180' : ''}`} />
</button>
),
);
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
const localize = useLocalize();
const showThinking = useRecoilValue<boolean>(store.showThinking);
const [isExpanded, setIsExpanded] = useState(showThinking);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
if (children == null) {
return null;
}
return (
<>
<div className="mb-5">
<ThinkingButton isExpanded={isExpanded} onClick={handleClick} label={label} />
</div>
<div
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent isPart={true}>{children}</ThinkingContent>
</div>
</div>
</>
);
});
ThinkingButton.displayName = 'ThinkingButton';
ThinkingContent.displayName = 'ThinkingContent';
Thinking.displayName = 'Thinking';
export default memo(Thinking);

View File

@@ -129,7 +129,11 @@ const BookmarkForm = ({
</div>
<div className="mt-4 grid w-full items-center gap-2">
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
<Label
id="bookmark-description-label"
htmlFor="bookmark-description"
className="text-left text-sm font-medium"
>
{localize('com_ui_bookmarks_description')}
</Label>
<TextareaAutosize
@@ -147,6 +151,7 @@ const BookmarkForm = ({
className={cn(
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
)}
aria-labelledby="bookmark-description-label"
/>
</div>
{conversationId != null && conversationId && (
@@ -161,6 +166,7 @@ const BookmarkForm = ({
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()}
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
/>
)}
/>

View File

@@ -12,6 +12,7 @@ import {
import {
useTextarea,
useAutoSave,
useLocalize,
useRequiresKey,
useHandleKeyUp,
useQueryParams,
@@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
const localize = useLocalize();
const [isCollapsed, setIsCollapsed] = useState(false);
const [, setIsScrollable] = useState(false);
@@ -220,6 +222,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
@@ -230,6 +233,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
{showMentionPopover && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
@@ -277,6 +281,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(

View File

@@ -117,8 +117,10 @@ const AttachFileMenu = ({
const items: MenuItemProps[] = [];
const currentProvider = provider || endpoint;
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
if (
isDocumentSupportedProvider(endpointType) ||
isDocumentSupportedProvider(currentProvider)
) {
items.push({
label: localize('com_ui_upload_provider'),
onClick: () => {

View File

@@ -57,7 +57,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
const currentProvider = provider || endpoint;
// Check if provider supports document upload
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
const isGoogleProvider = currentProvider === EModelEndpoint.google;
const validFileTypes = isGoogleProvider
? files.every(

View File

@@ -133,7 +133,7 @@ export default function FileRow({
>
{isImage ? (
<Image
url={file.preview ?? file.filepath}
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
onDelete={handleDelete}
progress={file.progress}
source={file.source}

View File

@@ -62,17 +62,28 @@ const FileUpload: React.FC<FileUploadProps> = ({
statusText = invalidText ?? localize('com_ui_upload_invalid');
}
const handleClick = () => {
const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
};
return (
<label
htmlFor={`file-upload-${id}`}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
<span className="flex text-xs">{statusText}</span>
<>
<button
type="button"
onClick={handleClick}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}
aria-label={statusText}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" aria-hidden="true" />
<span className="flex text-xs">{statusText}</span>
</button>
<input
id={`file-upload-${id}`}
value=""
@@ -80,8 +91,9 @@ const FileUpload: React.FC<FileUploadProps> = ({
className={cn('hidden', className)}
accept=".json"
onChange={handleFileChange}
tabIndex={-1}
/>
</label>
</>
);
};

View File

@@ -122,7 +122,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}>
<Button
variant="outline"
aria-label={localize('com_files_filter_by')}
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
>
<ListFilter className="size-3.5 sm:size-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,602 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EModelEndpoint } from 'librechat-data-provider';
import AttachFileMenu from '../AttachFileMenu';
// Mock all the hooks
jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(),
useGetAgentsConfig: jest.fn(),
useFileHandling: jest.fn(),
useLocalize: jest.fn(),
}));
jest.mock('~/hooks/Files/useSharePointFileHandling', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
useGetStartupConfig: jest.fn(),
}));
jest.mock('~/components/SharePoint', () => ({
SharePointPickerDialog: jest.fn(() => null),
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
return {
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
<div data-testid="file-upload">
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
{children}
</div>
)),
TooltipAnchor: ({ render }: any) => render,
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
const handleTriggerClick = () => {
if (setIsOpen) {
setIsOpen(!isOpen);
}
};
return (
<div>
<div onClick={handleTriggerClick}>{trigger}</div>
{isOpen && (
<div data-testid="dropdown-menu">
{items.map((item: any, idx: number) => (
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
{item.label}
</button>
))}
</div>
)}
</div>
);
},
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
};
});
jest.mock('@ariakit/react', () => ({
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
}));
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
const mockUseGetAgentsConfig = jest.requireMock('~/hooks').useGetAgentsConfig;
const mockUseFileHandling = jest.requireMock('~/hooks').useFileHandling;
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
const mockUseSharePointFileHandling = jest.requireMock(
'~/hooks/Files/useSharePointFileHandling',
).default;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
describe('AttachFileMenu', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const mockHandleFileChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
mockUseLocalize.mockReturnValue((key: string) => {
const translations: Record<string, string> = {
com_ui_upload_provider: 'Upload to Provider',
com_ui_upload_image_input: 'Upload Image',
com_ui_upload_ocr_text: 'Upload OCR Text',
com_ui_upload_file_search: 'Upload for File Search',
com_ui_upload_code_files: 'Upload Code Files',
com_sidepanel_attach_files: 'Attach Files',
com_files_upload_sharepoint: 'Upload from SharePoint',
};
return translations[key] || key;
});
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
});
mockUseGetAgentsConfig.mockReturnValue({
agentsConfig: {
capabilities: {
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: false,
},
},
});
mockUseFileHandling.mockReturnValue({
handleFileChange: mockHandleFileChange,
});
mockUseSharePointFileHandling.mockReturnValue({
handleSharePointFiles: jest.fn(),
isProcessing: false,
downloadProgress: 0,
});
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: false,
},
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
});
const renderAttachFileMenu = (props: any = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<AttachFileMenu conversationId="test-conversation" {...props} />
</RecoilRoot>
</QueryClientProvider>,
);
};
describe('Basic Rendering', () => {
it('should render the attachment button', () => {
renderAttachFileMenu();
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
it('should be disabled when disabled prop is true', () => {
renderAttachFileMenu({ disabled: true });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeDisabled();
});
it('should not be disabled when disabled prop is false', () => {
renderAttachFileMenu({ disabled: false });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).not.toBeDisabled();
});
});
describe('Provider Detection Fix - endpointType Priority', () => {
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
});
renderAttachFileMenu({
endpoint: 'litellm',
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
// With the fix, should show "Upload to Provider" because endpointType is checked first
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
});
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'my-custom-gateway',
});
renderAttachFileMenu({
endpoint: 'my-custom-gateway',
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should show Upload Image when neither endpointType nor provider support documents', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: 'unsupported-provider',
});
renderAttachFileMenu({
endpoint: 'unsupported-provider',
endpointType: 'unsupported-endpoint' as any,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload Image')).toBeInTheDocument();
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
});
it('should fallback to currentProvider when endpointType is undefined', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.openAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: undefined,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should fallback to currentProvider when endpointType is null', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.anthropic,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
});
describe('Supported Providers', () => {
const supportedProviders = [
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
{ name: 'Google', endpoint: EModelEndpoint.google },
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
{ name: 'Custom', endpoint: EModelEndpoint.custom },
];
supportedProviders.forEach(({ name, endpoint }) => {
it(`should show Upload to Provider for ${name}`, () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: endpoint,
});
renderAttachFileMenu({
endpoint,
endpointType: endpoint,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
});
});
describe('Agent Capabilities', () => {
it('should show OCR Text option when context is enabled', () => {
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: false,
codeEnabled: false,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
});
it('should show File Search option when enabled and allowed by agent', () => {
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
});
it('should NOT show File Search when enabled but not allowed by agent', () => {
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
});
it('should show Code Files option when enabled and allowed by agent', () => {
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: true,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: true,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
it('should show all options when all capabilities are enabled', () => {
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: true,
codeEnabled: true,
});
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: true,
provider: undefined,
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
});
describe('SharePoint Integration', () => {
it('should show SharePoint option when enabled', () => {
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: true,
},
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
});
it('should NOT show SharePoint option when disabled', () => {
mockUseGetStartupConfig.mockReturnValue({
data: {
sharePointFilePickerEnabled: false,
},
});
renderAttachFileMenu({
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle undefined endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: undefined,
});
renderAttachFileMenu({
endpoint: undefined,
endpointType: undefined,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
// Should show Upload Image as fallback
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
it('should handle null endpoint and provider gracefully', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: null,
});
renderAttachFileMenu({
endpoint: null,
endpointType: null,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
it('should handle missing agentId gracefully', () => {
renderAttachFileMenu({
agentId: undefined,
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
it('should handle empty string agentId', () => {
renderAttachFileMenu({
agentId: '',
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
});
});
describe('Google Provider Special Case', () => {
it('should use google_multimodal file type for Google provider', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.google,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
// Click the upload to provider option
fireEvent.click(uploadProviderButton);
// The file input should have been clicked (indirectly tested through the implementation)
});
it('should use multimodal file type for non-Google providers', () => {
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.openAI,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.openAI,
endpointType: EModelEndpoint.openAI,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
const uploadProviderButton = screen.getByText('Upload to Provider');
expect(uploadProviderButton).toBeInTheDocument();
fireEvent.click(uploadProviderButton);
// Implementation detail - multimodal type is used
});
});
describe('Regression Tests', () => {
it('should not break the previous behavior for direct provider attachments', () => {
// When using a direct supported provider (not through a gateway)
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.anthropic,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.anthropic,
endpointType: EModelEndpoint.anthropic,
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
it('should maintain correct priority when both are supported', () => {
// Both endpointType and provider are supported, endpointType should be checked first
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: false,
provider: EModelEndpoint.google,
});
renderAttachFileMenu({
endpoint: EModelEndpoint.google,
endpointType: EModelEndpoint.openAI, // Different but both supported
});
const button = screen.getByRole('button', { name: /attach file options/i });
fireEvent.click(button);
// Should still work because endpointType (openAI) is supported
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,121 @@
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
describe('DragDropModal - Provider Detection', () => {
describe('endpointType priority over currentProvider', () => {
it('should show upload option for LiteLLM with OpenAI endpointType', () => {
const currentProvider = 'litellm'; // NOT in documentSupportedProviders
const endpointType = EModelEndpoint.openAI; // IS in documentSupportedProviders
// With fix: endpointType checked
const withFix =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(withFix).toBe(true);
// Without fix: only currentProvider checked = false
const withoutFix = isDocumentSupportedProvider(currentProvider || endpointType);
expect(withoutFix).toBe(false);
});
it('should show upload option for any custom gateway with OpenAI endpointType', () => {
const currentProvider = 'my-custom-gateway';
const endpointType = EModelEndpoint.openAI;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should fallback to currentProvider when endpointType is undefined', () => {
const currentProvider = EModelEndpoint.openAI;
const endpointType = undefined;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should fallback to currentProvider when endpointType is null', () => {
const currentProvider = EModelEndpoint.anthropic;
const endpointType = null;
const result =
isDocumentSupportedProvider(endpointType as any) ||
isDocumentSupportedProvider(currentProvider);
expect(result).toBe(true);
});
it('should return false when neither provider supports documents', () => {
const currentProvider = 'unsupported-provider';
const endpointType = 'unsupported-endpoint' as any;
const result =
isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider);
expect(result).toBe(false);
});
});
describe('supported providers', () => {
const supportedProviders = [
{ name: 'OpenAI', value: EModelEndpoint.openAI },
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
{ name: 'Google', value: EModelEndpoint.google },
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
{ name: 'Custom', value: EModelEndpoint.custom },
];
supportedProviders.forEach(({ name, value }) => {
it(`should recognize ${name} as supported`, () => {
expect(isDocumentSupportedProvider(value)).toBe(true);
});
});
});
describe('real-world scenarios', () => {
it('should handle LiteLLM gateway pointing to OpenAI', () => {
const scenario = {
currentProvider: 'litellm',
endpointType: EModelEndpoint.openAI,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
it('should handle direct OpenAI connection', () => {
const scenario = {
currentProvider: EModelEndpoint.openAI,
endpointType: EModelEndpoint.openAI,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
it('should handle unsupported custom endpoint without override', () => {
const scenario = {
currentProvider: 'my-unsupported-endpoint',
endpointType: undefined,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(false);
});
it('should handle agents endpoints with document supported providers', () => {
const scenario = {
currentProvider: EModelEndpoint.google,
endpointType: EModelEndpoint.agents,
};
expect(
isDocumentSupportedProvider(scenario.endpointType) ||
isDocumentSupportedProvider(scenario.currentProvider),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,347 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FileSources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileRow from '../FileRow';
jest.mock('~/hooks', () => ({
useLocalize: jest.fn(),
}));
jest.mock('~/data-provider', () => ({
useDeleteFilesMutation: jest.fn(),
}));
jest.mock('~/hooks/Files', () => ({
useFileDeletion: jest.fn(),
}));
jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
},
}));
jest.mock('../Image', () => {
return function MockImage({ url, progress, source }: any) {
return (
<div data-testid="mock-image">
<span data-testid="image-url">{url}</span>
<span data-testid="image-progress">{progress}</span>
<span data-testid="image-source">{source}</span>
</div>
);
};
});
jest.mock('../FileContainer', () => {
return function MockFileContainer({ file }: any) {
return (
<div data-testid="mock-file-container">
<span data-testid="file-name">{file.filename}</span>
</div>
);
};
});
const mockUseLocalize = jest.requireMock('~/hooks').useLocalize;
const mockUseDeleteFilesMutation = jest.requireMock('~/data-provider').useDeleteFilesMutation;
const mockUseFileDeletion = jest.requireMock('~/hooks/Files').useFileDeletion;
describe('FileRow', () => {
const mockSetFiles = jest.fn();
const mockSetFilesLoading = jest.fn();
const mockDeleteFile = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseLocalize.mockReturnValue((key: string) => {
const translations: Record<string, string> = {
com_ui_deleting_file: 'Deleting file...',
};
return translations[key] || key;
});
mockUseDeleteFilesMutation.mockReturnValue({
mutateAsync: jest.fn(),
});
mockUseFileDeletion.mockReturnValue({
deleteFile: mockDeleteFile,
});
});
/**
* Creates a mock ExtendedFile with sensible defaults
*/
const createMockFile = (overrides: Partial<ExtendedFile> = {}): ExtendedFile => ({
file_id: 'test-file-id',
type: 'image/png',
preview: 'blob:http://localhost:3080/preview-blob-url',
filepath: '/images/user123/test-file-id__image.png',
filename: 'test-image.png',
progress: 1,
size: 1024,
source: FileSources.local,
...overrides,
});
const renderFileRow = (files: Map<string, ExtendedFile>) => {
return render(
<FileRow files={files} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
);
};
describe('Image URL Selection Logic', () => {
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
const file = createMockFile({
file_id: 'uploaded-file',
preview: 'blob:http://localhost:3080/temp-preview',
filepath: '/images/user123/uploaded-file__image.png',
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
expect(imageUrl).not.toContain('blob:');
});
it('should use preview when progress is less than 1 (uploading)', () => {
const file = createMockFile({
file_id: 'uploading-file',
preview: 'blob:http://localhost:3080/temp-preview',
filepath: undefined,
progress: 0.5,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
});
it('should fallback to filepath when preview is undefined and progress is less than 1', () => {
const file = createMockFile({
file_id: 'file-without-preview',
preview: undefined,
filepath: '/images/user123/file-without-preview__image.png',
progress: 0.7,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
});
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
const file = createMockFile({
file_id: 'complete-file',
preview: 'blob:http://localhost:3080/old-blob',
filepath: '/images/user123/complete-file__image.png',
progress: 1.0,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
});
});
describe('Progress States', () => {
it('should pass correct progress value during upload', () => {
const file = createMockFile({
progress: 0.65,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const progress = screen.getByTestId('image-progress').textContent;
expect(progress).toBe('0.65');
});
it('should pass progress value of 1 when upload is complete', () => {
const file = createMockFile({
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const progress = screen.getByTestId('image-progress').textContent;
expect(progress).toBe('1');
});
});
describe('File Source', () => {
it('should pass local source to Image component', () => {
const file = createMockFile({
source: FileSources.local,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const source = screen.getByTestId('image-source').textContent;
expect(source).toBe(FileSources.local);
});
it('should pass openai source to Image component', () => {
const file = createMockFile({
source: FileSources.openai,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const source = screen.getByTestId('image-source').textContent;
expect(source).toBe(FileSources.openai);
});
});
describe('File Type Detection', () => {
it('should render Image component for image files', () => {
const file = createMockFile({
type: 'image/jpeg',
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
expect(screen.getByTestId('mock-image')).toBeInTheDocument();
expect(screen.queryByTestId('mock-file-container')).not.toBeInTheDocument();
});
it('should render FileContainer for non-image files', () => {
const file = createMockFile({
type: 'application/pdf',
filename: 'document.pdf',
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
expect(screen.getByTestId('mock-file-container')).toBeInTheDocument();
expect(screen.queryByTestId('mock-image')).not.toBeInTheDocument();
});
});
describe('Multiple Files', () => {
it('should render multiple image files with correct URLs based on their progress', () => {
const filesMap = new Map<string, ExtendedFile>();
const uploadingFile = createMockFile({
file_id: 'file-1',
preview: 'blob:http://localhost:3080/preview-1',
filepath: undefined,
progress: 0.3,
});
const completedFile = createMockFile({
file_id: 'file-2',
preview: 'blob:http://localhost:3080/preview-2',
filepath: '/images/user123/file-2__image.png',
progress: 1,
});
filesMap.set(uploadingFile.file_id, uploadingFile);
filesMap.set(completedFile.file_id, completedFile);
renderFileRow(filesMap);
const images = screen.getAllByTestId('mock-image');
expect(images).toHaveLength(2);
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
expect(urls).toContain('blob:http://localhost:3080/preview-1');
expect(urls).toContain('/images/user123/file-2__image.png');
});
it('should deduplicate files with the same file_id', () => {
const filesMap = new Map<string, ExtendedFile>();
const file1 = createMockFile({ file_id: 'duplicate-id' });
const file2 = createMockFile({ file_id: 'duplicate-id' });
filesMap.set('key-1', file1);
filesMap.set('key-2', file2);
renderFileRow(filesMap);
const images = screen.getAllByTestId('mock-image');
expect(images).toHaveLength(1);
});
});
describe('Empty State', () => {
it('should render nothing when files map is empty', () => {
const filesMap = new Map<string, ExtendedFile>();
const { container } = renderFileRow(filesMap);
expect(container.firstChild).toBeNull();
});
it('should render nothing when files is undefined', () => {
const { container } = render(
<FileRow files={undefined} setFiles={mockSetFiles} setFilesLoading={mockSetFilesLoading} />,
);
expect(container.firstChild).toBeNull();
});
});
describe('Regression: Blob URL Bug Fix', () => {
it('should NOT use revoked blob URL after upload completes', () => {
const file = createMockFile({
file_id: 'regression-test',
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
progress: 1,
});
const filesMap = new Map<string, ExtendedFile>();
filesMap.set(file.file_id, file);
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).not.toContain('blob:');
expect(imageUrl).toBe(
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
);
});
});
});

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { useCombobox } from '@librechat/client';
import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { MentionOption, ConvoGenerator } from '~/common';
import type { SetterOrUpdater } from 'recoil';
import useSelectMention from '~/hooks/Input/useSelectMention';
@@ -14,6 +15,7 @@ import MentionItem from './MentionItem';
const ROW_HEIGHT = 40;
export default function Mention({
conversation,
setShowMentionPopover,
newConversation,
textAreaRef,
@@ -21,6 +23,7 @@ export default function Mention({
placeholder = 'com_ui_mention',
includeAssistants = true,
}: {
conversation: TConversation | null;
setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
@@ -42,6 +45,7 @@ export default function Mention({
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
conversation,
assistantsMap,
endpointsConfig,
newConversation,

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { EModelEndpoint } from 'librechat-data-provider';
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
import { useChatContext } from '~/Providers/ChatContext';
interface ModelSelectorChatContextValue {
@@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue {
spec?: string | null;
agent_id?: string | null;
assistant_id?: string | null;
conversation: TConversation | null;
newConversation: ReturnType<typeof useChatContext>['newConversation'];
}
@@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN
spec: conversation?.spec,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
conversation,
newConversation,
}),
[
conversation?.endpoint,
conversation?.model,
conversation?.spec,
conversation?.agent_id,
conversation?.assistant_id,
newConversation,
],
[conversation, newConversation],
);
return (

View File

@@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { endpoint, model, spec, agent_id, assistant_id, newConversation } =
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
useModelSelectorChatContext();
const modelSpecs = useMemo(() => {
const specs = startupConfig?.modelSpecs?.list ?? [];
@@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
// presets,
modelSpecs,
conversation,
assistantsMap,
endpointsConfig,
newConversation,

View File

@@ -1,8 +1,8 @@
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { clearMessagesCache } from '~/utils';
import { useLocalize } from '~/hooks';
export default function HeaderNewChat() {
@@ -15,10 +15,7 @@ export default function HeaderNewChat() {
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};

View File

@@ -59,9 +59,10 @@ const PresetItems: FC<{
</label>
<Dialog>
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
<button
type="button"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
aria-label={localize('com_ui_clear') + ' ' + localize('com_ui_all')}
>
<svg
width="24"
@@ -70,11 +71,12 @@ const PresetItems: FC<{
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="mr-1 flex w-[22px] items-center"
aria-hidden="true"
>
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
</svg>
{localize('com_ui_clear')} {localize('com_ui_all')}
</label>
</button>
</DialogTrigger>
<DialogTemplate
showCloseButton={false}

View File

@@ -1,5 +1,4 @@
import { memo, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { memo, useMemo } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import type {
TMessageContentParts,
@@ -7,14 +6,11 @@ import type {
TAttachment,
Agents,
} from 'librechat-data-provider';
import { ThinkingButton } from '~/components/Artifacts/Thinking';
import { MessageContext, SearchContext } from '~/Providers';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import { mapAttachments } from '~/utils/map';
import { EditTextPart } from './Parts';
import { useLocalize } from '~/hooks';
import store from '~/store';
import Part from './Part';
type ContentPartsProps = {
@@ -52,32 +48,10 @@ const ContentParts = memo(
siblingIdx,
setSiblingIdx,
}: ContentPartsProps) => {
const localize = useLocalize();
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
const [isExpanded, setIsExpanded] = useState(showThinking);
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const hasReasoningParts = useMemo(() => {
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
const allThinkPartsHaveContent =
content?.every((part) => {
if (part?.type !== ContentTypes.THINK) {
return true;
}
if (typeof part.think === 'string') {
const cleanedContent = part.think.replace(/<\/?think>/g, '').trim();
return cleanedContent.length > 0;
}
return false;
}) ?? false;
return hasThinkPart && allThinkPartsHaveContent;
}, [content]);
if (!content) {
return null;
}
@@ -126,57 +100,40 @@ const ContentParts = memo(
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{hasReasoningParts && (
<div className="mb-5">
<ThinkingButton
isExpanded={isExpanded}
onClick={() =>
setIsExpanded((prev) => {
const val = !prev;
setShowThinking(val);
return val;
})
}
label={
effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
: localize('com_ui_thoughts')
}
/>
</div>
)}
{content
.filter((part) => part)
.map((part, idx) => {
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const attachments = attachmentMap[toolCallId];
{content.map((part, idx) => {
if (!part) {
return null;
}
return (
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded,
conversationId,
partIndex: idx,
nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={attachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1}
showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
return (
<MessageContext.Provider
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded: true,
conversationId,
partIndex: idx,
nextType: content[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={idx === content.length - 1}
showCursor={idx === content.length - 1 && isLast}
/>
</MessageContext.Provider>
);
})}
</SearchContext.Provider>
</>
);

View File

@@ -151,7 +151,7 @@ const EditMessage = ({
return (
<Container message={message}>
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
<div className="bg-token-main-surface-primary relative mt-2 flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
<TextareaAutosize
{...registerProps}
ref={(e) => {
@@ -168,6 +168,7 @@ const EditMessage = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings,
)}
aria-label={localize('com_ui_message_input')}
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>

View File

@@ -4,67 +4,89 @@ import { DelayedRender } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common';
import Error from '~/components/Messages/Content/Error';
import Thinking from '~/components/Artifacts/Thinking';
import { useMessageContext } from '~/Providers';
import MarkdownLite from './MarkdownLite';
import EditMessage from './EditMessage';
import Thinking from './Parts/Thinking';
import { useLocalize } from '~/hooks';
import Container from './Container';
import Markdown from './Markdown';
import { cn } from '~/utils';
import store from '~/store';
const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.';
const DELAYED_ERROR_TIMEOUT = 5500;
const UNFINISHED_DELAY = 250;
const parseThinkingContent = (text: string) => {
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
return {
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
};
};
const LoadingFallback = () => (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
</p>
</div>
</div>
</div>
);
const ErrorBox = ({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) => (
<div
role="alert"
aria-live="assertive"
className={cn(
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
{children}
</div>
);
const ConnectionError = ({ message }: { message?: TMessage }) => {
const localize = useLocalize();
return (
<Suspense fallback={<LoadingFallback />}>
<DelayedRender delay={DELAYED_ERROR_TIMEOUT}>
<Container message={message}>
<div className="mt-2 rounded-xl border border-red-500/20 bg-red-50/50 px-4 py-3 text-sm text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100">
{localize('com_ui_error_connection')}
</div>
</Container>
</DelayedRender>
</Suspense>
);
};
export const ErrorMessage = ({
text,
message,
className = '',
}: Pick<TDisplayProps, 'text' | 'className'> & {
message?: TMessage;
}) => {
const localize = useLocalize();
if (text === 'Error connecting to server, try refreshing the page.') {
console.log('error message', message);
return (
<Suspense
fallback={
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
</p>
</div>
</div>
</div>
}
>
<DelayedRender delay={5500}>
<Container message={message}>
<div
className={cn(
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
{localize('com_ui_error_connection')}
</div>
</Container>
</DelayedRender>
</Suspense>
);
}: Pick<TDisplayProps, 'text' | 'className'> & { message?: TMessage }) => {
if (text === ERROR_CONNECTION_TEXT) {
return <ConnectionError message={message} />;
}
return (
<Container message={message}>
<div
role="alert"
aria-live="assertive"
className={cn(
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
<ErrorBox className={className}>
<Error text={text} />
</div>
</ErrorBox>
</Container>
);
};
@@ -72,27 +94,29 @@ export const ErrorMessage = ({
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(
() => showCursor === true && isSubmitting,
[showCursor, isSubmitting],
);
let content: React.ReactElement;
if (!isCreatedByUser) {
content = <Markdown content={text} isLatestMessage={isLatestMessage} />;
} else if (enableUserMsgMarkdown) {
content = <MarkdownLite content={text} />;
} else {
content = <>{text}</>;
}
const content = useMemo(() => {
if (!isCreatedByUser) {
return <Markdown content={text} isLatestMessage={isLatestMessage} />;
}
if (enableUserMsgMarkdown) {
return <MarkdownLite content={text} />;
}
return <>{text}</>;
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
return (
<Container message={message}>
<div
className={cn(
isSubmitting ? 'submitting' : '',
showCursorState && !!text.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
isSubmitting && 'submitting',
showCursorState && text.length > 0 && 'result-streaming',
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
)}
@@ -103,7 +127,6 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay
);
};
// Unfinished Message Component
export const UnfinishedMessage = ({ message }: { message: TMessage }) => (
<ErrorMessage
message={message}
@@ -123,21 +146,14 @@ const MessageContent = ({
const { message } = props;
const { messageId } = message;
const { thinkingContent, regularContent } = useMemo(() => {
const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/);
return {
thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '',
regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text,
};
}, [text]);
const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]);
const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]);
const unfinishedMessage = useMemo(
() =>
!isSubmitting && unfinished ? (
<Suspense>
<DelayedRender delay={250}>
<DelayedRender delay={UNFINISHED_DELAY}>
<UnfinishedMessage message={message} />
</DelayedRender>
</Suspense>
@@ -146,8 +162,10 @@ const MessageContent = ({
);
if (error) {
return <ErrorMessage message={props.message} text={text} />;
} else if (edit) {
return <ErrorMessage message={message} text={text} />;
}
if (edit) {
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
}

View File

@@ -65,6 +65,10 @@ const Part = memo(
if (part.tool_call_ids != null && !text) {
return null;
}
/** Skip rendering if text is only whitespace to avoid empty Container */
if (!isLast && text.length > 0 && /^\s*$/.test(text)) {
return null;
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
@@ -75,7 +79,7 @@ const Part = memo(
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} />;
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} else if (part.type === ContentTypes.TOOL_CALL) {
const toolCall = part[ContentTypes.TOOL_CALL];

View File

@@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { ContentTypes } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Lightbulb, MessageSquare } from 'lucide-react';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common';
@@ -153,6 +154,22 @@ const EditTextPart = ({
return (
<Container message={message}>
{part.type === ContentTypes.THINK && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
<Lightbulb className="size-3.5" />
{localize('com_ui_thoughts')}
</span>
</div>
)}
{part.type !== ContentTypes.THINK && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-text-secondary">
<span className="flex gap-2 rounded-lg bg-surface-tertiary px-1.5 py-1 font-medium">
<MessageSquare className="size-3.5" />
{localize('com_ui_response')}
</span>
</div>
)}
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-border-medium text-text-primary [&:has(textarea:focus)]:border-border-heavy [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
<TextareaAutosize
{...registerProps}
@@ -170,6 +187,7 @@ const EditTextPart = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings,
)}
aria-label={localize('com_ui_editable_message')}
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>

View File

@@ -1,15 +1,47 @@
import { memo, useMemo } from 'react';
import { memo, useMemo, useState, useCallback } from 'react';
import { useAtom } from 'jotai';
import type { MouseEvent } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import { ThinkingContent } from '~/components/Artifacts/Thinking';
import { ThinkingContent, ThinkingButton } from './Thinking';
import { showThinkingAtom } from '~/store/showThinking';
import { useMessageContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type ReasoningProps = {
reasoning: string;
isLast: boolean;
};
const Reasoning = memo(({ reasoning }: ReasoningProps) => {
const { isExpanded, nextType } = useMessageContext();
/**
* Reasoning Component (MODERN SYSTEM)
*
* Used for structured content parts with ContentTypes.THINK type.
* This handles modern message format where content is an array of typed parts.
*
* Pattern: `{ content: [{ type: "think", think: "<think>content</think>" }, ...] }`
*
* Used by:
* - ContentParts.tsx → Part.tsx for structured messages
* - Agent/Assistant responses (OpenAI Assistants, custom agents)
* - O-series models (o1, o3) with reasoning capabilities
* - Modern Claude responses with thinking blocks
*
* Key differences from legacy Thinking.tsx:
* - Works with content parts array instead of plain text
* - Strips `<think>` tags instead of `:::thinking:::` markers
* - Each THINK part has its own independent toggle button
* - Can be interleaved with other content types
*
* For legacy text-based messages, see Thinking.tsx component.
*/
const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const localize = useLocalize();
const [showThinking] = useAtom(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
// Strip <think> tags from the reasoning content (modern format)
const reasoningText = useMemo(() => {
return reasoning
.replace(/^<think>\s*/, '')
@@ -17,22 +49,45 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => {
.trim();
}, [reasoning]);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
const label = useMemo(
() =>
effectiveIsSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts'),
[effectiveIsSubmitting, localize, isLast],
);
if (!reasoningText) {
return null;
}
return (
<div
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-8',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent isPart={true}>{reasoningText}</ThinkingContent>
<div className="group/reasoning">
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={reasoningText}
/>
</div>
<div
className={cn(
'grid transition-all duration-300 ease-out',
nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
)}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,172 @@
import { useState, useMemo, memo, useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { Lightbulb, ChevronDown } from 'lucide-react';
import { Clipboard, CheckMark } from '@librechat/client';
import type { MouseEvent, FC } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import { fontSizeAtom } from '~/store/fontSize';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
/**
* ThinkingContent - Displays the actual thinking/reasoning content
* Used by both legacy text-based messages and modern content parts
*/
export const ThinkingContent: FC<{
children: React.ReactNode;
}> = memo(({ children }) => {
const fontSize = useAtomValue(fontSizeAtom);
return (
<div className="relative rounded-3xl border border-border-medium bg-surface-tertiary p-4 text-text-secondary">
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
</div>
);
});
/**
* ThinkingButton - Toggle button for expanding/collapsing thinking content
* Shows lightbulb icon by default, chevron on hover
* Shared between legacy Thinking component and modern ContentParts
*/
export const ThinkingButton = memo(
({
isExpanded,
onClick,
label,
content,
}: {
isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string;
content?: string;
}) => {
const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom);
const [isCopied, setIsCopied] = useState(false);
const handleCopy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (content) {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}
},
[content],
);
return (
<div className="flex w-full items-center justify-between gap-2">
<button
type="button"
onClick={onClick}
className={cn(
'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]',
fontSize,
)}
>
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
<Lightbulb className="icon-sm absolute text-text-secondary opacity-100 transition-opacity group-hover/button:opacity-0" />
<ChevronDown
className={cn(
'icon-sm absolute transform-gpu text-text-primary opacity-0 transition-all duration-300 group-hover/button:opacity-100',
isExpanded && 'rotate-180',
)}
/>
</span>
{label}
</button>
{content && (
<button
type="button"
onClick={handleCopy}
title={
isCopied
? localize('com_ui_copied_to_clipboard')
: localize('com_ui_copy_thoughts_to_clipboard')
}
className={cn(
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
)}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
</button>
)}
</div>
);
},
);
/**
* Thinking Component (LEGACY SYSTEM)
*
* Used for simple text-based messages with `:::thinking:::` markers.
* This handles the old message format where text contains embedded thinking blocks.
*
* Pattern: `:::thinking\n{content}\n:::\n{response}`
*
* Used by:
* - MessageContent.tsx for plain text messages
* - Legacy message format compatibility
* - User messages when manually adding thinking content
*
* For modern structured content (agents/assistants), see Reasoning.tsx component.
*/
const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => {
const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom);
const [isExpanded, setIsExpanded] = useState(showThinking);
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsExpanded((prev) => !prev);
}, []);
const label = useMemo(() => localize('com_ui_thoughts'), [localize]);
// Extract text content for copy functionality
const textContent = useMemo(() => {
if (typeof children === 'string') {
return children;
}
return '';
}, [children]);
if (children == null) {
return null;
}
return (
<>
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
<ThinkingButton
isExpanded={isExpanded}
onClick={handleClick}
label={label}
content={textContent}
/>
</div>
<div
className={cn('grid transition-all duration-300 ease-out', isExpanded && 'mb-8')}
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr',
}}
>
<div className="overflow-hidden">
<ThinkingContent>{children}</ThinkingContent>
</div>
</div>
</>
);
});
ThinkingButton.displayName = 'ThinkingButton';
ThinkingContent.displayName = 'ThinkingContent';
Thinking.displayName = 'Thinking';
export default memo(Thinking);

View File

@@ -1,10 +1,12 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts';
import { fontSizeAtom } from '~/store/fontSize';
import SiblingSwitch from './SiblingSwitch';
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
@@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) {
regenerateMessage,
} = useMessageHelpers(props);
const fontSize = useRecoilValue(store.fontSize);
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const { children, messageId = null, isCreatedByUser } = message ?? {};

View File

@@ -1,10 +1,12 @@
import { useState } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { MessagesViewProvider } from '~/Providers';
import { fontSizeAtom } from '~/store/fontSize';
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
import store from '~/store';
@@ -15,7 +17,7 @@ function MessagesViewContent({
messagesTree?: TMessage[] | null;
}) {
const localize = useLocalize();
const fontSize = useRecoilValue(store.fontSize);
const fontSize = useAtomValue(fontSizeAtom);
const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { useAuthContext, useLocalize } from '~/hooks';
import type { TMessageProps, TMessageIcon } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import SearchContent from './Content/SearchContent';
import { fontSizeAtom } from '~/store/fontSize';
import SearchButtons from './SearchButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
@@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
);
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
const fontSize = useAtomValue(fontSizeAtom);
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const fontSize = useRecoilValue(store.fontSize);
const { user } = useAuthContext();
const localize = useLocalize();

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
@@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
@@ -58,8 +60,8 @@ const MessageRender = memo(
isMultiMessage,
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const hasNoChildren = !(msg?.children?.length ?? 0);

View File

@@ -201,7 +201,6 @@ const Conversations: FC<ConversationsProps> = ({
overscanRowCount={10}
className="outline-none"
style={{ outline: 'none' }}
role="list"
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -82,7 +82,7 @@ export function DeleteConversationDialog({
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader>
<div>
<div className="w-full truncate">
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
</div>
<div className="flex justify-end gap-4 pt-4">

View File

@@ -77,7 +77,13 @@ export default function ShareButton({
<div className="relative items-center rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
<QRCodeSVG
value={sharedLink}
size={200}
marginSize={2}
className="rounded-2xl"
title={localize('com_ui_share_qr_code_description')}
/>
</div>
)}
@@ -87,6 +93,7 @@ export default function ShareButton({
<Button
size="sm"
variant="outline"
aria-label={localize('com_ui_copy_link')}
onClick={() => {
if (isCopying) {
return;

View File

@@ -34,6 +34,8 @@ const RenameForm: React.FC<RenameFormProps> = ({
case 'Enter':
onSubmit(titleInput);
break;
case 'Tab':
break;
}
};
@@ -50,22 +52,23 @@ const RenameForm: React.FC<RenameFormProps> = ({
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(titleInput)}
maxLength={100}
aria-label={localize('com_ui_new_conversation_title')}
/>
<div className="flex gap-1" role="toolbar">
<button
onClick={() => onCancel()}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_cancel')}
type="button"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
<button
onClick={() => onSubmit(titleInput)}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2"
className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_save')}
type="button"
>
<Check className="h-4 w-4" aria-hidden="true" />
</button>

View File

@@ -151,6 +151,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
@@ -160,7 +161,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</Label>
<InputNumber
id="top-p-int"
@@ -189,6 +192,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
@@ -199,7 +203,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_frequency_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="freq-penalty-int"
@@ -228,6 +234,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
@@ -238,7 +245,9 @@ export default function Settings({
<div className="flex justify-between">
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_presence_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="pres-penalty-int"
@@ -267,6 +276,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
@@ -306,6 +316,7 @@ export default function Settings({
onCheckedChange={(checked: boolean) => setResendFiles(checked)}
disabled={readonly}
className="flex"
aria-label={localize('com_endpoint_plug_resend_files')}
/>
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
</HoverCardTrigger>
@@ -323,6 +334,7 @@ export default function Settings({
max={2}
min={0}
step={1}
aria-label={localize('com_endpoint_plug_image_detail')}
/>
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
</HoverCardTrigger>

View File

@@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label>
<InputNumber
id="temp-int"
@@ -82,6 +84,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeAgent}
disabled={readonly}
className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_use_functions')}
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} />
@@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeSkip}
disabled={readonly}
className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_skip_completion')}
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} />

View File

@@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.temperature.min}
step={google.temperature.step}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topP.min}
step={google.topP.step}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topK.min}
step={google.topK.step}
className="flex h-4 w-full"
aria-labelledby="top-k-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
@@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.maxOutputTokens.min}
step={google.maxOutputTokens.step}
className="flex h-4 w-full"
aria-labelledby="max-tokens-int"
/>
</HoverCardTrigger>
<OptionHover

View File

@@ -256,6 +256,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@@ -296,6 +297,7 @@ export default function Settings({
min={0}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@@ -337,6 +339,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} />
@@ -378,6 +381,7 @@ export default function Settings({
min={-2}
step={0.01}
className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} />

View File

@@ -1,5 +1,6 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
@@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { fontSizeAtom } from '~/store/fontSize';
import { cn, logger } from '~/utils';
import store from '~/store';
@@ -60,8 +62,8 @@ const ContentRender = memo(
isMultiMessage,
setCurrentEditId,
});
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(

View File

@@ -124,13 +124,15 @@ export default function ExportModal({
disabled={!exportOptionsSupport}
checked={includeOptions}
onCheckedChange={setIncludeOptions}
aria-labelledby="includeOptions-label"
/>
<label
id="includeOptions-label"
htmlFor="includeOptions"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportOptionsSupport
? localize('com_nav_enabled')
? localize('com_nav_export_include_endpoint_options')
: localize('com_nav_not_supported')}
</label>
</div>
@@ -146,13 +148,15 @@ export default function ExportModal({
disabled={!exportBranchesSupport}
checked={exportBranches}
onCheckedChange={setExportBranches}
aria-labelledby="exportBranches-label"
/>
<label
id="exportBranches-label"
htmlFor="exportBranches"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>
{exportBranchesSupport
? localize('com_nav_enabled')
? localize('com_nav_export_all_message_branches')
: localize('com_nav_not_supported')}
</label>
</div>
@@ -163,8 +167,14 @@ export default function ExportModal({
{localize('com_nav_export_recursive_or_sequential')}
</Label>
<div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} />
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={setRecursive}
aria-labelledby="recursive-label"
/>
<label
id="recursive-label"
htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
>

View File

@@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { Dispatch, SetStateAction } from 'react';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function MobileNav({
@@ -57,10 +58,7 @@ export default function MobileNav({
aria-label={localize('com_ui_new_chat')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
onClick={() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}}

View File

@@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function NewChat({
@@ -33,10 +34,7 @@ export default function NewChat({
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConvo();
navigate('/c/new', { state: { focusChat: true } });

View File

@@ -1,4 +1,5 @@
import { memo } from 'react';
import { showThinkingAtom } from '~/store/showThinking';
import FontSizeSelector from './FontSizeSelector';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
@@ -28,7 +29,7 @@ const toggleSwitchConfigs = [
key: 'centerFormOnLanding',
},
{
stateAtom: store.showThinking,
stateAtom: showThinkingAtom,
localizationKey: 'com_nav_show_thinking',
switchId: 'showThinking',
hoverCardText: undefined,

View File

@@ -1,15 +1,14 @@
import { useRecoilState } from 'recoil';
import { Dropdown, applyFontSize } from '@librechat/client';
import { useAtom } from 'jotai';
import { Dropdown } from '@librechat/client';
import { fontSizeAtom } from '~/store/fontSize';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function FontSizeSelector() {
const [fontSize, setFontSize] = useRecoilState(store.fontSize);
const localize = useLocalize();
const [fontSize, setFontSize] = useAtom(fontSizeAtom);
const handleChange = (val: string) => {
setFontSize(val);
applyFontSize(val);
};
const options = [

View File

@@ -30,6 +30,7 @@ export default function SaveBadgesState({
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="saveBadgesState"
aria-label={localize('com_nav_save_badges_state')}
/>
</div>
);

View File

@@ -1,18 +1,18 @@
import { useRecoilState } from 'recoil';
import { useAtom } from 'jotai';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { showThinkingAtom } from '~/store/showThinking';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function SaveDraft({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [showThinking, setSaveDrafts] = useRecoilState<boolean>(store.showThinking);
const [showThinking, setShowThinking] = useAtom(showThinkingAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSaveDrafts(value);
setShowThinking(value);
if (onCheckedChange) {
onCheckedChange(value);
}
@@ -30,6 +30,7 @@ export default function SaveDraft({
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="showThinking"
aria-label={localize('com_nav_show_thinking')}
/>
</div>
);

View File

@@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils';
function ImportConversations() {
const queryClient = useQueryClient();
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const handleSuccess = useCallback(() => {
@@ -53,7 +51,8 @@ function ImportConversations() {
const handleFileUpload = useCallback(
async (file: File) => {
try {
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize;
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
if (maxFileSize && file.size > maxFileSize) {
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
showToast({
@@ -76,7 +75,7 @@ function ImportConversations() {
});
}
},
[uploadFile, showToast, localize, startupConfig],
[uploadFile, showToast, localize, queryClient],
);
const handleFileChange = useCallback(

View File

@@ -13,7 +13,6 @@ import {
useMediaQuery,
OGDialogHeader,
OGDialogTitle,
TooltipAnchor,
DataTable,
Spinner,
Button,
@@ -246,37 +245,27 @@ export default function SharedLinks() {
},
cell: ({ row }) => (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_view_source')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
title={localize('com_ui_view_source')}
>
<MessageSquare className="size-4" />
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
/>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
window.open(`/c/${row.original.conversationId}`, '_blank');
}}
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
>
<MessageSquare className="size-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
>
<TrashIcon className="size-4" aria-hidden="true" />
</Button>
</div>
),
},

View File

@@ -1,3 +1,4 @@
import { WritableAtom, useAtom } from 'jotai';
import { RecoilState, useRecoilState } from 'recoil';
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
import { useLocalize } from '~/hooks';
@@ -6,7 +7,7 @@ type LocalizeFn = ReturnType<typeof useLocalize>;
type LocalizeKey = Parameters<LocalizeFn>[0];
interface ToggleSwitchProps {
stateAtom: RecoilState<boolean>;
stateAtom: RecoilState<boolean> | WritableAtom<boolean, [boolean], void>;
localizationKey: LocalizeKey;
hoverCardText?: LocalizeKey;
switchId: string;
@@ -16,13 +17,18 @@ interface ToggleSwitchProps {
strongLabel?: boolean;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
function isRecoilState<T>(atom: unknown): atom is RecoilState<T> {
return atom != null && typeof atom === 'object' && 'key' in atom;
}
const RecoilToggle: React.FC<
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: RecoilState<boolean> }
> = ({
stateAtom,
localizationKey,
hoverCardText,
switchId,
onCheckedChange,
showSwitch = true,
disabled = false,
strongLabel = false,
}) => {
@@ -36,9 +42,47 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
const labelId = `${switchId}-label`;
if (!showSwitch) {
return null;
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div id={labelId}>
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div>
<Switch
id={switchId}
checked={switchState}
onCheckedChange={handleCheckedChange}
disabled={disabled}
className="ml-4"
data-testid={switchId}
aria-labelledby={labelId}
/>
</div>
);
};
const JotaiToggle: React.FC<
Omit<ToggleSwitchProps, 'stateAtom'> & { stateAtom: WritableAtom<boolean, [boolean], void> }
> = ({
stateAtom,
localizationKey,
hoverCardText,
switchId,
onCheckedChange,
disabled = false,
strongLabel = false,
}) => {
const [switchState, setSwitchState] = useAtom(stateAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSwitchState(value);
onCheckedChange?.(value);
};
const labelId = `${switchId}-label`;
return (
<div className="flex items-center justify-between">
@@ -52,13 +96,29 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
id={switchId}
checked={switchState}
onCheckedChange={handleCheckedChange}
disabled={disabled}
className="ml-4"
data-testid={switchId}
aria-labelledby={labelId}
disabled={disabled}
/>
</div>
);
};
const ToggleSwitch: React.FC<ToggleSwitchProps> = (props) => {
const { stateAtom, showSwitch = true } = props;
if (!showSwitch) {
return null;
}
const isRecoil = isRecoilState(stateAtom);
if (isRecoil) {
return <RecoilToggle {...props} stateAtom={stateAtom as RecoilState<boolean>} />;
}
return <JotaiToggle {...props} stateAtom={stateAtom as WritableAtom<boolean, [boolean], void>} />;
};
export default ToggleSwitch;

View File

@@ -53,6 +53,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
}
}}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
@@ -216,7 +217,12 @@ const AdminSettings = () => {
))}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting || isLoading} variant="submit">
<Button
type="submit"
disabled={isSubmitting || isLoading}
variant="submit"
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>

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