From c30afb8b68cbf68379190fb0a726fd50f2bcd688 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 7 Jan 2026 20:37:35 -0500 Subject: [PATCH 001/282] =?UTF-8?q?=F0=9F=9A=8F=20chore:=20Remove=20Resuma?= =?UTF-8?q?ble=20Stream=20Toggle=20(#11258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸš chore: Remove Resumable Stream Toggle - Removed the `useResumableStreamToggle` hook and its associated logic from the ChatView component. - Updated Conversations and useAdaptiveSSE hooks to determine resumable stream status based on the endpoint type. - Cleaned up settings by removing the `resumableStreams` state from the store and its related localization strings. * šŸ”§ refactor: Simplify Active Jobs Logic in Conversations Component - Removed the endpoint type checks and associated logic for resumable streams in the Conversations component. - Updated the `useActiveJobs` hook call to no longer depend on resumable stream status, streamlining the data fetching process. --- client/src/components/Chat/ChatView.tsx | 13 +----- .../Conversations/Conversations.tsx | 3 +- .../components/Nav/SettingsTabs/Chat/Chat.tsx | 39 ++++++++---------- client/src/hooks/SSE/index.ts | 1 - client/src/hooks/SSE/useAdaptiveSSE.ts | 13 +++--- .../src/hooks/SSE/useResumableStreamToggle.ts | 41 ------------------- client/src/hooks/SSE/useResumeOnLoad.ts | 8 +++- client/src/locales/en/translation.json | 2 - client/src/store/settings.ts | 1 - 9 files changed, 32 insertions(+), 89 deletions(-) delete mode 100644 client/src/hooks/SSE/useResumableStreamToggle.ts diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index d2e107ae19..66dec68f64 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -7,13 +7,7 @@ import { Constants, buildTree } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import type { ChatFormValues } from '~/common'; import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers'; -import { - useResumableStreamToggle, - useAddedResponse, - useResumeOnLoad, - useAdaptiveSSE, - useChatHelpers, -} from '~/hooks'; +import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks'; import ConversationStarters from './Input/ConversationStarters'; import { useGetMessagesByConvoId } from '~/data-provider'; import MessagesView from './Messages/MessagesView'; @@ -56,11 +50,6 @@ function ChatView({ index = 0 }: { index?: number }) { const chatHelpers = useChatHelpers(index, conversationId); const addedChatHelpers = useAddedResponse(); - useResumableStreamToggle( - chatHelpers.conversation?.endpoint, - chatHelpers.conversation?.endpointType, - ); - useAdaptiveSSE(rootSubmission, chatHelpers, false, index); // Auto-resume if navigating back to conversation with active job diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index f0b05a5a00..b972d251b0 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -160,14 +160,13 @@ const Conversations: FC = ({ }) => { const localize = useLocalize(); const search = useRecoilValue(store.search); - const resumableEnabled = useRecoilValue(store.resumableStreams); const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; const showAgentMarketplace = useShowMarketplace(); // Fetch active job IDs for showing generation indicators - const { data: activeJobsData } = useActiveJobs(resumableEnabled); + const { data: activeJobsData } = useActiveJobs(); const activeJobIds = useMemo( () => new Set(activeJobsData?.activeJobIds ?? []), [activeJobsData?.activeJobIds], diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx index bfedd22c74..5cbbd73619 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx @@ -9,88 +9,81 @@ import store from '~/store'; const toggleSwitchConfigs = [ { stateAtom: store.enterToSend, - localizationKey: 'com_nav_enter_to_send', + localizationKey: 'com_nav_enter_to_send' as const, switchId: 'enterToSend', - hoverCardText: 'com_nav_info_enter_to_send', + hoverCardText: 'com_nav_info_enter_to_send' as const, key: 'enterToSend', }, { stateAtom: store.maximizeChatSpace, - localizationKey: 'com_nav_maximize_chat_space', + localizationKey: 'com_nav_maximize_chat_space' as const, switchId: 'maximizeChatSpace', hoverCardText: undefined, key: 'maximizeChatSpace', }, { stateAtom: store.centerFormOnLanding, - localizationKey: 'com_nav_center_chat_input', + localizationKey: 'com_nav_center_chat_input' as const, switchId: 'centerFormOnLanding', hoverCardText: undefined, key: 'centerFormOnLanding', }, { stateAtom: showThinkingAtom, - localizationKey: 'com_nav_show_thinking', + localizationKey: 'com_nav_show_thinking' as const, switchId: 'showThinking', hoverCardText: undefined, key: 'showThinking', }, { stateAtom: store.showCode, - localizationKey: 'com_nav_show_code', + localizationKey: 'com_nav_show_code' as const, switchId: 'showCode', hoverCardText: undefined, key: 'showCode', }, { stateAtom: store.LaTeXParsing, - localizationKey: 'com_nav_latex_parsing', + localizationKey: 'com_nav_latex_parsing' as const, switchId: 'latexParsing', - hoverCardText: 'com_nav_info_latex_parsing', + hoverCardText: 'com_nav_info_latex_parsing' as const, key: 'latexParsing', }, { stateAtom: store.saveDrafts, - localizationKey: 'com_nav_save_drafts', + localizationKey: 'com_nav_save_drafts' as const, switchId: 'saveDrafts', - hoverCardText: 'com_nav_info_save_draft', + hoverCardText: 'com_nav_info_save_draft' as const, key: 'saveDrafts', }, { stateAtom: store.showScrollButton, - localizationKey: 'com_nav_scroll_button', + localizationKey: 'com_nav_scroll_button' as const, switchId: 'showScrollButton', hoverCardText: undefined, key: 'showScrollButton', }, { stateAtom: store.saveBadgesState, - localizationKey: 'com_nav_save_badges_state', + localizationKey: 'com_nav_save_badges_state' as const, switchId: 'showBadges', - hoverCardText: 'com_nav_info_save_badges_state', + hoverCardText: 'com_nav_info_save_badges_state' as const, key: 'showBadges', }, { stateAtom: store.modularChat, - localizationKey: 'com_nav_modular_chat', + localizationKey: 'com_nav_modular_chat' as const, switchId: 'modularChat', hoverCardText: undefined, key: 'modularChat', }, { stateAtom: store.defaultTemporaryChat, - localizationKey: 'com_nav_default_temporary_chat', + localizationKey: 'com_nav_default_temporary_chat' as const, switchId: 'defaultTemporaryChat', - hoverCardText: 'com_nav_info_default_temporary_chat', + hoverCardText: 'com_nav_info_default_temporary_chat' as const, key: 'defaultTemporaryChat', }, - { - stateAtom: store.resumableStreams, - localizationKey: 'com_nav_resumable_streams', - switchId: 'resumableStreams', - hoverCardText: 'com_nav_info_resumable_streams', - key: 'resumableStreams', - }, ]; function Chat() { diff --git a/client/src/hooks/SSE/index.ts b/client/src/hooks/SSE/index.ts index 800de1e2a7..2829db76f6 100644 --- a/client/src/hooks/SSE/index.ts +++ b/client/src/hooks/SSE/index.ts @@ -5,4 +5,3 @@ export { default as useResumeOnLoad } from './useResumeOnLoad'; export { default as useStepHandler } from './useStepHandler'; export { default as useContentHandler } from './useContentHandler'; export { default as useAttachmentHandler } from './useAttachmentHandler'; -export { default as useResumableStreamToggle } from './useResumableStreamToggle'; diff --git a/client/src/hooks/SSE/useAdaptiveSSE.ts b/client/src/hooks/SSE/useAdaptiveSSE.ts index b196e4ef0c..e8c2de08e0 100644 --- a/client/src/hooks/SSE/useAdaptiveSSE.ts +++ b/client/src/hooks/SSE/useAdaptiveSSE.ts @@ -1,9 +1,8 @@ -import { useRecoilValue } from 'recoil'; +import { isAssistantsEndpoint } from 'librechat-data-provider'; import type { TSubmission } from 'librechat-data-provider'; import type { EventHandlerParams } from './useEventHandlers'; -import useSSE from './useSSE'; import useResumableSSE from './useResumableSSE'; -import store from '~/store'; +import useSSE from './useSSE'; type ChatHelpers = Pick< EventHandlerParams, @@ -17,7 +16,7 @@ type ChatHelpers = Pick< /** * Adaptive SSE hook that switches between standard and resumable modes. - * Uses Recoil state to determine which mode to use. + * Uses resumable streams by default, falls back to standard SSE for assistants endpoints. * * Note: Both hooks are always called to comply with React's Rules of Hooks. * We pass null submission to the inactive one. @@ -28,7 +27,11 @@ export default function useAdaptiveSSE( isAddedRequest = false, runIndex = 0, ) { - const resumableEnabled = useRecoilValue(store.resumableStreams); + const endpoint = submission?.conversation?.endpoint; + const endpointType = submission?.conversation?.endpointType; + const actualEndpoint = endpointType ?? endpoint; + const isAssistants = isAssistantsEndpoint(actualEndpoint); + const resumableEnabled = !isAssistants; useSSE(resumableEnabled ? null : submission, chatHelpers, isAddedRequest, runIndex); diff --git a/client/src/hooks/SSE/useResumableStreamToggle.ts b/client/src/hooks/SSE/useResumableStreamToggle.ts deleted file mode 100644 index f14fde044a..0000000000 --- a/client/src/hooks/SSE/useResumableStreamToggle.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useRecoilState } from 'recoil'; -import { isAssistantsEndpoint } from 'librechat-data-provider'; -import type { EModelEndpoint } from 'librechat-data-provider'; -import store from '~/store'; - -/** - * Automatically toggles resumable streams off for assistants endpoints - * and restores the previous value when switching away. - * - * Assistants endpoints have their own streaming mechanism and don't support resumable streams. - */ -export default function useResumableStreamToggle( - endpoint: EModelEndpoint | string | null | undefined, - endpointType?: EModelEndpoint | string | null, -) { - const [resumableStreams, setResumableStreams] = useRecoilState(store.resumableStreams); - const savedValueRef = useRef(null); - const wasAssistantsRef = useRef(false); - - useEffect(() => { - const actualEndpoint = endpointType ?? endpoint; - const isAssistants = isAssistantsEndpoint(actualEndpoint); - - if (isAssistants && !wasAssistantsRef.current) { - // Switching TO assistants: save current value and disable - savedValueRef.current = resumableStreams; - if (resumableStreams) { - setResumableStreams(false); - } - wasAssistantsRef.current = true; - } else if (!isAssistants && wasAssistantsRef.current) { - // Switching AWAY from assistants: restore saved value - if (savedValueRef.current !== null) { - setResumableStreams(savedValueRef.current); - savedValueRef.current = null; - } - wasAssistantsRef.current = false; - } - }, [endpoint, endpointType, resumableStreams, setResumableStreams]); -} diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts index 5a674cec75..f09751db0e 100644 --- a/client/src/hooks/SSE/useResumeOnLoad.ts +++ b/client/src/hooks/SSE/useResumeOnLoad.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useSetRecoilState, useRecoilValue } from 'recoil'; -import { Constants, tMessageSchema } from 'librechat-data-provider'; +import { Constants, tMessageSchema, isAssistantsEndpoint } from 'librechat-data-provider'; import type { TMessage, TConversation, TSubmission, Agents } from 'librechat-data-provider'; import { useStreamStatus } from '~/data-provider'; import store from '~/store'; @@ -102,9 +102,13 @@ export default function useResumeOnLoad( runIndex = 0, messagesLoaded = true, ) { - const resumableEnabled = useRecoilValue(store.resumableStreams); const setSubmission = useSetRecoilState(store.submissionByIndex(runIndex)); const currentSubmission = useRecoilValue(store.submissionByIndex(runIndex)); + const currentConversation = useRecoilValue(store.conversationByIndex(runIndex)); + const endpoint = currentConversation?.endpoint; + const endpointType = currentConversation?.endpointType; + const actualEndpoint = endpointType ?? endpoint; + const resumableEnabled = !isAssistantsEndpoint(actualEndpoint); // Track conversations we've already processed (either resumed or skipped) const processedConvoRef = useRef(null); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a1b55cf451..c144e8bda5 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -485,7 +485,6 @@ "com_nav_info_fork_split_target_setting": "When enabled, forking will commence from the target message to the latest message in the conversation, according to the behavior selected.", "com_nav_info_include_shadcnui": "When enabled, instructions for using shadcn/ui components will be included. shadcn/ui is a collection of re-usable components built using Radix UI and Tailwind CSS. Note: these are lengthy instructions, you should only enable if informing the LLM of the correct imports and components is important to you. For more information about these components, visit: https://ui.shadcn.com/", "com_nav_info_latex_parsing": "When enabled, LaTeX code in messages will be rendered as mathematical equations. Disabling this may improve performance if you don't need LaTeX rendering.", - "com_nav_info_resumable_streams": "When enabled, LLM generation continues in the background even if your connection drops. You can reconnect and resume receiving the response without losing progress. This is useful for unstable connections or long responses.", "com_nav_info_save_badges_state": "When enabled, the state of the chat badges will be saved. This means that if you create a new chat, the badges will remain in the same state as the previous chat. If you disable this option, the badges will reset to their default state every time you create a new chat", "com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.", "com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface", @@ -556,7 +555,6 @@ "com_nav_plus_command": "+-Command", "com_nav_plus_command_description": "Toggle command \"+\" for adding a multi-response setting", "com_nav_profile_picture": "Profile Picture", - "com_nav_resumable_streams": "Resumable Streams (Beta)", "com_nav_save_badges_state": "Save badges state", "com_nav_save_drafts": "Save drafts locally", "com_nav_scroll_button": "Scroll to the end button", diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index db5200d1ee..ece96d119a 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -42,7 +42,6 @@ const localStorageAtoms = { LaTeXParsing: atomWithLocalStorage('LaTeXParsing', true), centerFormOnLanding: atomWithLocalStorage('centerFormOnLanding', true), showFooter: atomWithLocalStorage('showFooter', true), - resumableStreams: atomWithLocalStorage('resumableStreams', true), // Commands settings atCommand: atomWithLocalStorage('atCommand', true), From 6680ccf63bcdf79f47efcae88bf62efe6935cb2e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:00:28 -0800 Subject: [PATCH 002/282] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Model=20List=20Qu?= =?UTF-8?q?ery=20Data=20in=20Agent=20Builder=20Panel=20(#11260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: don't populate query with initial data for getModels query hook to avoid caching issue when opening model list in agent builder after hard refresh / switching to Agent Marketplace view * fix: reduce scope of change --- client/src/components/SidePanel/Agents/AgentPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 2cf6af3f7d..86ec27dc5e 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -217,7 +217,7 @@ export default function AgentPanel() { const { onSelect: onSelectAgent } = useSelectAgent(); - const modelsQuery = useGetModelsQuery(); + const modelsQuery = useGetModelsQuery({ refetchOnMount: 'always' }); const basicAgentQuery = useGetAgentByIdQuery(current_agent_id); const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( From f2e4cd5026e8de7110505413586ca6e2edfb376c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:00:52 -0500 Subject: [PATCH 003/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index f2913f9d98..4beb0815c8 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -485,7 +485,6 @@ "com_nav_info_fork_split_target_setting": "Ja Ŕī opcija ir iespējota, atzaroÅ”ana sāksies no mērÄ·a ziņas uz jaunāko sarunas ziņu atbilstoÅ”i atlasÄ«tajai darbÄ«bai.", "com_nav_info_include_shadcnui": "Ja Ŕī opcija ir iespējota, tiks iekļautas instrukcijas par shadcn/ui komponentu lietoÅ”anu. shadcn/ui ir atkārtoti izmantojamu komponentu kolekcija, kas izveidota, izmantojot Radix UI un Tailwind CSS. PiezÄ«me. Å Ä«s ir garas instrukcijas, tās vajadzētu iespējot tikai tad, ja jums ir svarÄ«gi informēt LLM par pareiziem importēŔanas datiem un komponentiem. Lai iegÅ«tu plaŔāku informāciju par Å”iem komponentiem, apmeklējiet vietni: https://ui.shadcn.com/", "com_nav_info_latex_parsing": "Ja Ŕī opcija ir iespējota, LaTeX kods ziņās tiks atveidots kā matemātiski vienādojumi. Å Ä«s opcijas atspējoÅ”ana var uzlabot veiktspēju, ja LaTeX atveidoÅ”ana nav nepiecieÅ”ama.", - "com_nav_info_resumable_streams": "Ja Ŕī funkcija ir iespējota, LLM Ä£enerēŔana turpinās fonā pat tad, ja savienojums pārtrÅ«kst. Varat atkārtoti izveidot savienojumu un atsākt atbildes saņemÅ”anu, nezaudējot progresu. Tas ir noderÄ«gi nestabiliem savienojumiem vai garām atbildēm.", "com_nav_info_save_badges_state": "Ja Ŕī opcija ir iespējota, sarunu nozÄ«mīŔu stāvoklis tiks saglabāts. Tas nozÄ«mē, ka, izveidojot jaunu sarunu, nozÄ«mÄ«tes paliks tādā paŔā stāvoklÄ« kā iepriekŔējā sarunā. Ja atspējosiet Å”o opciju, nozÄ«mÄ«tes tiks atiestatÄ«tas uz noklusējuma stāvokli katru reizi, kad izveidosiet jaunu sarunu.", "com_nav_info_save_draft": "Ja Ŕī opcija ir iespējota, sarunas veidlapā ievadÄ«tais teksts un pielikumi tiks automātiski saglabāti lokāli kā melnraksti. Å ie melnraksti bÅ«s pieejami pat tad, ja atkārtoti ielādēsiet lapu vai pārslēgsieties uz citu sarunu. Melnraksti tiek saglabāti lokāli jÅ«su ierÄ«cē un tiek dzēsti, tiklÄ«dz ziņa ir nosÅ«tÄ«ts.", "com_nav_info_show_thinking": "Ja Ŕī opcija ir iespējota, sarunas pēc noklusējuma tiks atvērtas domāŔanas nolaižamās izvēlnes, ļaujot reāllaikā skatÄ«t mākslÄ«gā intelekta sprieÅ”anu. Ja Ŕī opcija ir atspējota, domāŔanas nolaižamās izvēlnes pēc noklusējuma paliks aizvērtas, lai saskarne bÅ«tu tÄ«rāka un vienkārŔāka.", @@ -556,7 +555,6 @@ "com_nav_plus_command": "+-komanda", "com_nav_plus_command_description": "Pārslēgt komandu \"+\", lai pievienotu vairāku atbilžu iestatÄ«jumu", "com_nav_profile_picture": "Profila attēls", - "com_nav_resumable_streams": "Atsākama straumēŔana (beta versija)", "com_nav_save_badges_state": "Saglabāt nozÄ«mīŔu stāvokli", "com_nav_save_drafts": "Saglabāt melnrakstus lokāli", "com_nav_scroll_button": "RādÄ«t pogu: pāriet uz pēdējo ierakstu", From 87c817a5eb828a86e4598fea1c42457383ef0af5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 8 Jan 2026 18:57:28 -0500 Subject: [PATCH 004/282] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Invalidate=20Quer?= =?UTF-8?q?y=20for=20MCP=20tools=20on=20Chat=20Creation=20(#11272)=20(#112?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/SSE/useEventHandlers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 570b548394..9f809bd6c1 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -345,6 +345,7 @@ export default function useEventHandlers({ const createdHandler = useCallback( (data: TResData, submission: EventSubmission) => { queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + queryClient.invalidateQueries([QueryKeys.mcpTools]); const { messages, userMessage, isRegenerate = false, isTemporary = false } = submission; const initialResponse = { ...submission.initialResponse, From 7d38047bc2c11540d12e11205656fea9fce2348e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 8 Jan 2026 19:20:08 -0500 Subject: [PATCH 005/282] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Update=20react-?= =?UTF-8?q?router=20to=20v6.30.3=20and=20@remix-run/router=20to=20v1.23.2?= =?UTF-8?q?=20(#11273)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package.json | 2 +- package-lock.json | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/client/package.json b/client/package.json index af56dd08ad..81b2fdf255 100644 --- a/client/package.json +++ b/client/package.json @@ -96,7 +96,7 @@ "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", - "react-router-dom": "^6.11.2", + "react-router-dom": "^6.30.3", "react-speech-recognition": "^3.10.0", "react-textarea-autosize": "^8.4.0", "react-transition-group": "^4.4.5", diff --git a/package-lock.json b/package-lock.json index 66f2233095..3337696267 100644 --- a/package-lock.json +++ b/package-lock.json @@ -514,7 +514,7 @@ "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^3.0.6", - "react-router-dom": "^6.11.2", + "react-router-dom": "^6.30.3", "react-speech-recognition": "^3.10.0", "react-textarea-autosize": "^8.4.0", "react-transition-group": "^4.4.5", @@ -18100,9 +18100,10 @@ } }, "node_modules/@remix-run/router": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz", - "integrity": "sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -37344,11 +37345,12 @@ } }, "node_modules/react-router": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.0.tgz", - "integrity": "sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.15.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -37358,12 +37360,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.0.tgz", - "integrity": "sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.15.0", - "react-router": "6.22.0" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" From 083251508edd44003774fac7cff54cf32bf928ea Mon Sep 17 00:00:00 2001 From: Scott Finlay Date: Fri, 9 Jan 2026 20:34:30 +0100 Subject: [PATCH 006/282] =?UTF-8?q?=E2=8F=AD=EF=B8=8F=20fix:=20Skip=20Titl?= =?UTF-8?q?e=20Generation=20for=20Temporary=20Chats=20(#11282)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Not generating titles for temporary chats * Minor linter fix to prettify debug line * Adding a test for skipping title generation for temporary chats --- api/server/controllers/agents/client.js | 8 ++++++++ api/server/controllers/agents/client.test.js | 19 +++++++++++++++++++ api/server/services/Endpoints/agents/title.js | 5 +++++ .../services/Endpoints/assistants/title.js | 5 +++++ 4 files changed, 37 insertions(+) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2601fb3be0..79e63d1c7f 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1120,6 +1120,14 @@ class AgentClient extends BaseClient { } const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator(); const { req, agent } = this.options; + + if (req?.body?.isTemporary) { + logger.debug( + `[api/server/controllers/agents/client.js #titleConvo] Skipping title generation for temporary conversation`, + ); + return; + } + const appConfig = req.config; let endpoint = agent.endpoint; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index f8abf60955..14f0df9bb0 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -336,6 +336,25 @@ describe('AgentClient - titleConvo', () => { expect(client.recordCollectedUsage).not.toHaveBeenCalled(); }); + it('should skip title generation for temporary chats', async () => { + // Set isTemporary to true + mockReq.body.isTemporary = true; + + const text = 'Test temporary chat'; + const abortController = new AbortController(); + + const result = await client.titleConvo({ text, abortController }); + + // Should return undefined without generating title + expect(result).toBeUndefined(); + + // generateTitle should NOT have been called + expect(mockRun.generateTitle).not.toHaveBeenCalled(); + + // recordCollectedUsage should NOT have been called + expect(client.recordCollectedUsage).not.toHaveBeenCalled(); + }); + it('should skip title generation when titleConvo is false in all config', async () => { // Set titleConvo to false in "all" config mockReq.config = { diff --git a/api/server/services/Endpoints/agents/title.js b/api/server/services/Endpoints/agents/title.js index 74cdc0b2c2..1d6d359bd6 100644 --- a/api/server/services/Endpoints/agents/title.js +++ b/api/server/services/Endpoints/agents/title.js @@ -17,6 +17,11 @@ const addTitle = async (req, { text, response, client }) => { return; } + // Skip title generation for temporary conversations + if (req?.body?.isTemporary) { + return; + } + const titleCache = getLogStores(CacheKeys.GEN_TITLE); const key = `${req.user.id}-${response.conversationId}`; /** @type {NodeJS.Timeout} */ diff --git a/api/server/services/Endpoints/assistants/title.js b/api/server/services/Endpoints/assistants/title.js index 020549a1be..a34de4d1af 100644 --- a/api/server/services/Endpoints/assistants/title.js +++ b/api/server/services/Endpoints/assistants/title.js @@ -50,6 +50,11 @@ const addTitle = async (req, { text, responseText, conversationId }) => { return; } + // Skip title generation for temporary conversations + if (req?.body?.isTemporary) { + return; + } + const titleCache = getLogStores(CacheKeys.GEN_TITLE); const key = `${req.user.id}-${conversationId}`; From 76e17ba70172b7b3c2e2493a00493133b857e818 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 10 Jan 2026 14:02:56 -0500 Subject: [PATCH 007/282] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Permission?= =?UTF-8?q?=20handling=20for=20Resource=20Sharing=20(#11283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ refactor: permission handling for public sharing - Updated permission keys from SHARED_GLOBAL to SHARE across various files for consistency. - Added public access configuration in librechat.example.yaml. - Adjusted related tests and components to reflect the new permission structure. * chore: Update default SHARE permission to false * fix: Update SHARE permissions in tests and implementation - Added SHARE permission handling for user and admin roles in permissions.spec.ts and permissions.ts. - Updated expected permissions in tests to reflect new SHARE permission values for various permission types. * fix: Handle undefined values in PeoplePickerAdminSettings component - Updated the checked and value props of the Switch component to handle undefined values gracefully by defaulting to false. This ensures consistent behavior when the field value is not set. * feat: Add CREATE permission handling for prompts and agents - Introduced CREATE permission for user and admin roles in permissions.spec.ts and permissions.ts. - Updated expected permissions in tests to include CREATE permission for various permission types. * šŸ”§ refactor: Enhance permission handling for sharing dialog usability * refactor: public sharing permissions for resources - Added middleware to check SHARE_PUBLIC permissions for agents, prompts, and MCP servers. - Updated interface configuration in librechat.example.yaml to include public sharing options. - Enhanced components and hooks to support public sharing functionality. - Adjusted tests to validate new permission handling for public sharing across various resource types. * refactor: update Share2Icon styling in GenericGrantAccessDialog * refactor: update Share2Icon size in GenericGrantAccessDialog for consistency * refactor: improve layout and styling of Share2Icon in GenericGrantAccessDialog * refactor: update Share2Icon size in GenericGrantAccessDialog for improved consistency * chore: remove redundant public sharing option from People Picker * refactor: add SHARE_PUBLIC permission handling in updateInterfacePermissions tests --- api/models/Role.spec.js | 50 +++--- .../canAccessAgentResource.spec.js | 2 +- .../canAccessMCPServerResource.spec.js | 4 +- .../accessResources/fileAccess.spec.js | 2 +- .../middleware/checkSharePublicAccess.js | 84 +++++++++ .../middleware/checkSharePublicAccess.spec.js | 164 ++++++++++++++++++ api/server/middleware/roles/access.spec.js | 16 +- api/server/routes/accessPermissions.js | 3 + api/server/routes/agents/v1.js | 2 +- api/server/routes/prompts.js | 2 +- api/server/routes/prompts.test.js | 2 +- .../src/components/Prompts/AdminSettings.tsx | 5 +- client/src/components/Prompts/PromptForm.tsx | 2 +- client/src/components/Prompts/SharePrompt.tsx | 4 +- .../Sharing/GenericGrantAccessDialog.tsx | 45 +++-- .../Sharing/PeoplePickerAdminSettings.tsx | 4 +- .../SidePanel/Agents/AdminSettings.tsx | 5 +- .../SidePanel/Agents/AgentFooter.tsx | 2 +- .../SidePanel/MCPBuilder/MCPAdminSettings.tsx | 1 + client/src/hooks/Sharing/index.ts | 1 + client/src/hooks/Sharing/useCanSharePublic.ts | 22 +++ .../Sharing/usePeoplePickerPermissions.ts | 1 + client/src/locales/en/translation.json | 3 + librechat.example.yaml | 14 +- packages/api/src/app/permissions.spec.ts | 149 +++++++++++++++- packages/api/src/app/permissions.ts | 68 +++++++- packages/api/src/middleware/access.spec.ts | 20 +-- packages/data-provider/src/config.ts | 36 +++- packages/data-provider/src/permissions.ts | 12 +- packages/data-provider/src/roles.ts | 16 +- packages/data-schemas/src/schema/role.ts | 7 +- packages/data-schemas/src/types/role.ts | 7 +- 32 files changed, 646 insertions(+), 109 deletions(-) create mode 100644 api/server/middleware/checkSharePublicAccess.js create mode 100644 api/server/middleware/checkSharePublicAccess.spec.js create mode 100644 client/src/hooks/Sharing/useCanSharePublic.ts diff --git a/api/models/Role.spec.js b/api/models/Role.spec.js index c344f719dd..deac4e5c35 100644 --- a/api/models/Role.spec.js +++ b/api/models/Role.spec.js @@ -46,7 +46,7 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }, }, }).save(); @@ -55,7 +55,7 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: true, + SHARE: true, }, }); @@ -63,7 +63,7 @@ describe('updateAccessPermissions', () => { expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: true, - SHARED_GLOBAL: true, + SHARE: true, }); }); @@ -74,7 +74,7 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }, }, }).save(); @@ -83,7 +83,7 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }, }); @@ -91,7 +91,7 @@ describe('updateAccessPermissions', () => { expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }); }); @@ -110,20 +110,20 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }, }, }).save(); await updateAccessPermissions(SystemRoles.USER, { - [PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true }, + [PermissionTypes.PROMPTS]: { SHARE: true }, }); const updatedRole = await getRoleByName(SystemRoles.USER); expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: true, - SHARED_GLOBAL: true, + SHARE: true, }); }); @@ -134,7 +134,7 @@ describe('updateAccessPermissions', () => { [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, - SHARED_GLOBAL: false, + SHARE: false, }, }, }).save(); @@ -147,7 +147,7 @@ describe('updateAccessPermissions', () => { expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: false, - SHARED_GLOBAL: false, + SHARE: false, }); }); @@ -155,13 +155,13 @@ describe('updateAccessPermissions', () => { await new Role({ name: SystemRoles.USER, permissions: { - [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false }, + [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false }, [PermissionTypes.BOOKMARKS]: { USE: true }, }, }).save(); await updateAccessPermissions(SystemRoles.USER, { - [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true }, + [PermissionTypes.PROMPTS]: { USE: false, SHARE: true }, [PermissionTypes.BOOKMARKS]: { USE: false }, }); @@ -169,7 +169,7 @@ describe('updateAccessPermissions', () => { expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: false, - SHARED_GLOBAL: true, + SHARE: true, }); expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false }); }); @@ -178,19 +178,19 @@ describe('updateAccessPermissions', () => { await new Role({ name: SystemRoles.USER, permissions: { - [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false }, + [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false }, }, }).save(); await updateAccessPermissions(SystemRoles.USER, { - [PermissionTypes.PROMPTS]: { USE: false, SHARED_GLOBAL: true }, + [PermissionTypes.PROMPTS]: { USE: false, SHARE: true }, }); const updatedRole = await getRoleByName(SystemRoles.USER); expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: false, - SHARED_GLOBAL: true, + SHARE: true, }); }); @@ -214,13 +214,13 @@ describe('updateAccessPermissions', () => { await new Role({ name: SystemRoles.USER, permissions: { - [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARED_GLOBAL: false }, + [PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false }, [PermissionTypes.MULTI_CONVO]: { USE: false }, }, }).save(); await updateAccessPermissions(SystemRoles.USER, { - [PermissionTypes.PROMPTS]: { SHARED_GLOBAL: true }, + [PermissionTypes.PROMPTS]: { SHARE: true }, [PermissionTypes.MULTI_CONVO]: { USE: true }, }); @@ -228,7 +228,7 @@ describe('updateAccessPermissions', () => { expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({ CREATE: true, USE: true, - SHARED_GLOBAL: true, + SHARE: true, }); expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true }); }); @@ -271,7 +271,7 @@ describe('initializeRoles', () => { }); // Example: Check default values for ADMIN role - expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBe(true); + expect(adminRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true); expect(adminRole.permissions[PermissionTypes.BOOKMARKS].USE).toBe(true); expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBe(true); }); @@ -283,7 +283,7 @@ describe('initializeRoles', () => { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false, [Permissions.CREATE]: true, - [Permissions.SHARED_GLOBAL]: true, + [Permissions.SHARE]: true, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, }, @@ -320,7 +320,7 @@ describe('initializeRoles', () => { expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined(); expect(userRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined(); expect(userRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined(); - expect(userRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined(); + expect(userRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined(); }); it('should handle multiple runs without duplicating or modifying data', async () => { @@ -348,7 +348,7 @@ describe('initializeRoles', () => { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false, [Permissions.CREATE]: false, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, [PermissionTypes.BOOKMARKS]: roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS], @@ -365,7 +365,7 @@ describe('initializeRoles', () => { expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined(); expect(adminRole.permissions[PermissionTypes.AGENTS].CREATE).toBeDefined(); expect(adminRole.permissions[PermissionTypes.AGENTS].USE).toBeDefined(); - expect(adminRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeDefined(); + expect(adminRole.permissions[PermissionTypes.AGENTS].SHARE).toBeDefined(); }); it('should include MULTI_CONVO permissions when creating default roles', async () => { diff --git a/api/server/middleware/accessResources/canAccessAgentResource.spec.js b/api/server/middleware/accessResources/canAccessAgentResource.spec.js index e3dca73bd2..1106390e72 100644 --- a/api/server/middleware/accessResources/canAccessAgentResource.spec.js +++ b/api/server/middleware/accessResources/canAccessAgentResource.spec.js @@ -29,7 +29,7 @@ describe('canAccessAgentResource middleware', () => { AGENTS: { USE: true, CREATE: true, - SHARED_GLOBAL: false, + SHARE: true, }, }, }); diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js index 5eef1438ff..075cddb000 100644 --- a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js @@ -26,10 +26,10 @@ describe('canAccessMCPServerResource middleware', () => { await Role.create({ name: 'test-role', permissions: { - MCPSERVERS: { + MCP_SERVERS: { USE: true, CREATE: true, - SHARED_GLOBAL: false, + SHARE: true, }, }, }); diff --git a/api/server/middleware/accessResources/fileAccess.spec.js b/api/server/middleware/accessResources/fileAccess.spec.js index de7c7d50f6..cc0d57ac48 100644 --- a/api/server/middleware/accessResources/fileAccess.spec.js +++ b/api/server/middleware/accessResources/fileAccess.spec.js @@ -32,7 +32,7 @@ describe('fileAccess middleware', () => { AGENTS: { USE: true, CREATE: true, - SHARED_GLOBAL: false, + SHARE: true, }, }, }); diff --git a/api/server/middleware/checkSharePublicAccess.js b/api/server/middleware/checkSharePublicAccess.js new file mode 100644 index 0000000000..c094d54acb --- /dev/null +++ b/api/server/middleware/checkSharePublicAccess.js @@ -0,0 +1,84 @@ +const { logger } = require('@librechat/data-schemas'); +const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider'); +const { getRoleByName } = require('~/models/Role'); + +/** + * Maps resource types to their corresponding permission types + */ +const resourceToPermissionType = { + [ResourceType.AGENT]: PermissionTypes.AGENTS, + [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, + [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, +}; + +/** + * Middleware to check if user has SHARE_PUBLIC permission for a resource type + * Only enforced when request body contains `public: true` + * @param {import('express').Request} req - Express request + * @param {import('express').Response} res - Express response + * @param {import('express').NextFunction} next - Express next function + */ +const checkSharePublicAccess = async (req, res, next) => { + try { + const { public: isPublic } = req.body; + + // Only check if trying to enable public sharing + if (!isPublic) { + return next(); + } + + const user = req.user; + if (!user || !user.role) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + } + + const { resourceType } = req.params; + const permissionType = resourceToPermissionType[resourceType]; + + if (!permissionType) { + return res.status(400).json({ + error: 'Bad Request', + message: `Unsupported resource type for public sharing: ${resourceType}`, + }); + } + + const role = await getRoleByName(user.role); + if (!role || !role.permissions) { + return res.status(403).json({ + error: 'Forbidden', + message: 'No permissions configured for user role', + }); + } + + const resourcePerms = role.permissions[permissionType] || {}; + const canSharePublic = resourcePerms[Permissions.SHARE_PUBLIC] === true; + + if (!canSharePublic) { + logger.warn( + `[checkSharePublicAccess][${user.id}] User denied SHARE_PUBLIC for ${resourceType}`, + ); + return res.status(403).json({ + error: 'Forbidden', + message: `You do not have permission to share ${resourceType} resources publicly`, + }); + } + + next(); + } catch (error) { + logger.error( + `[checkSharePublicAccess][${req.user?.id}] Error checking SHARE_PUBLIC permission`, + error, + ); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to check public sharing permissions', + }); + } +}; + +module.exports = { + checkSharePublicAccess, +}; diff --git a/api/server/middleware/checkSharePublicAccess.spec.js b/api/server/middleware/checkSharePublicAccess.spec.js new file mode 100644 index 0000000000..c73e71693b --- /dev/null +++ b/api/server/middleware/checkSharePublicAccess.spec.js @@ -0,0 +1,164 @@ +const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider'); +const { checkSharePublicAccess } = require('./checkSharePublicAccess'); +const { getRoleByName } = require('~/models/Role'); + +jest.mock('~/models/Role'); + +describe('checkSharePublicAccess middleware', () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + jest.clearAllMocks(); + mockReq = { + user: { id: 'user123', role: 'USER' }, + params: { resourceType: ResourceType.AGENT }, + body: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + mockNext = jest.fn(); + }); + + it('should call next() when public is not true', async () => { + mockReq.body = { public: false }; + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should call next() when public is undefined', async () => { + mockReq.body = { updated: [] }; + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should return 401 when user is not authenticated', async () => { + mockReq.body = { public: true }; + mockReq.user = null; + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Authentication required', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 403 when user role has no SHARE_PUBLIC permission for agents', async () => { + mockReq.body = { public: true }; + mockReq.params = { resourceType: ResourceType.AGENT }; + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: false, + }, + }, + }); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Forbidden', + message: `You do not have permission to share ${ResourceType.AGENT} resources publicly`, + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should call next() when user has SHARE_PUBLIC permission for agents', async () => { + mockReq.body = { public: true }; + mockReq.params = { resourceType: ResourceType.AGENT }; + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should check prompts permission for promptgroup resource type', async () => { + mockReq.body = { public: true }; + mockReq.params = { resourceType: ResourceType.PROMPTGROUP }; + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.PROMPTS]: { + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should check mcp_servers permission for mcpserver resource type', async () => { + mockReq.body = { public: true }; + mockReq.params = { resourceType: ResourceType.MCPSERVER }; + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should return 400 for unsupported resource type', async () => { + mockReq.body = { public: true }; + mockReq.params = { resourceType: 'unsupported' }; + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Bad Request', + message: 'Unsupported resource type for public sharing: unsupported', + }); + }); + + it('should return 403 when role has no permissions object', async () => { + mockReq.body = { public: true }; + getRoleByName.mockResolvedValue({ permissions: null }); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(403); + }); + + it('should return 500 on error', async () => { + mockReq.body = { public: true }; + getRoleByName.mockRejectedValue(new Error('Database error')); + + await checkSharePublicAccess(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Internal Server Error', + message: 'Failed to check public sharing permissions', + }); + }); +}); diff --git a/api/server/middleware/roles/access.spec.js b/api/server/middleware/roles/access.spec.js index fe8d77a4f5..9de840819d 100644 --- a/api/server/middleware/roles/access.spec.js +++ b/api/server/middleware/roles/access.spec.js @@ -51,9 +51,9 @@ describe('Access Middleware', () => { permissions: { [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.PROMPTS]: { - [Permissions.SHARED_GLOBAL]: false, [Permissions.USE]: true, [Permissions.CREATE]: true, + [Permissions.SHARE]: true, }, [PermissionTypes.MEMORIES]: { [Permissions.USE]: true, @@ -65,7 +65,7 @@ describe('Access Middleware', () => { [PermissionTypes.AGENTS]: { [Permissions.USE]: true, [Permissions.CREATE]: false, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -79,9 +79,9 @@ describe('Access Middleware', () => { permissions: { [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.PROMPTS]: { - [Permissions.SHARED_GLOBAL]: true, [Permissions.USE]: true, [Permissions.CREATE]: true, + [Permissions.SHARE]: true, }, [PermissionTypes.MEMORIES]: { [Permissions.USE]: true, @@ -93,7 +93,7 @@ describe('Access Middleware', () => { [PermissionTypes.AGENTS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, - [Permissions.SHARED_GLOBAL]: true, + [Permissions.SHARE]: true, }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, @@ -110,7 +110,7 @@ describe('Access Middleware', () => { [PermissionTypes.AGENTS]: { [Permissions.USE]: false, [Permissions.CREATE]: false, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, // Has permissions for other types [PermissionTypes.PROMPTS]: { @@ -241,7 +241,7 @@ describe('Access Middleware', () => { req: {}, user: { id: 'admin123', role: 'admin' }, permissionType: PermissionTypes.AGENTS, - permissions: [Permissions.SHARED_GLOBAL], + permissions: [Permissions.SHARE], getRoleByName, }); expect(shareResult).toBe(true); @@ -318,7 +318,7 @@ describe('Access Middleware', () => { const middleware = generateCheckAccess({ permissionType: PermissionTypes.AGENTS, - permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL], + permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE], getRoleByName, }); await middleware(req, res, next); @@ -349,7 +349,7 @@ describe('Access Middleware', () => { [PermissionTypes.AGENTS]: { [Permissions.USE]: false, [Permissions.CREATE]: false, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, }, }); diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 3e70f2610f..2cfd19289d 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -10,6 +10,7 @@ const { } = require('~/server/controllers/PermissionsController'); const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess'); +const { checkSharePublicAccess } = require('~/server/middleware/checkSharePublicAccess'); const { findMCPServerById } = require('~/models'); const router = express.Router(); @@ -91,10 +92,12 @@ router.get( * PUT /api/permissions/{resourceType}/{resourceId} * Bulk update permissions for a specific resource * SECURITY: Requires SHARE permission to modify resource permissions + * SECURITY: Requires SHARE_PUBLIC permission to enable public sharing */ router.put( '/:resourceType/:resourceId', checkResourcePermissionAccess(PermissionBits.SHARE), + checkSharePublicAccess, updateResourcePermissions, ); diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 1e4f1c0118..682a9c795f 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -25,7 +25,7 @@ const checkGlobalAgentShare = generateCheckAccess({ permissionType: PermissionTypes.AGENTS, permissions: [Permissions.USE, Permissions.CREATE], bodyProps: { - [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], + [Permissions.SHARE]: ['projectIds', 'removeProjectIds'], }, getRoleByName, }); diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index c833719075..037bf04813 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -60,7 +60,7 @@ const checkGlobalPromptShare = generateCheckAccess({ permissionType: PermissionTypes.PROMPTS, permissions: [Permissions.USE, Permissions.CREATE], bodyProps: { - [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], + [Permissions.SHARE]: ['projectIds', 'removeProjectIds'], }, getRoleByName, }); diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index 1aeca1c93c..caeb90ddfb 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -159,7 +159,7 @@ async function setupTestData() { case SystemRoles.USER: return { permissions: { PROMPTS: { USE: true, CREATE: true } } }; case SystemRoles.ADMIN: - return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARED_GLOBAL: true } } }; + return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARE: true } } }; default: return null; } diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 6a5d6a7152..6d382fbb91 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -8,9 +8,10 @@ import { useLocalize } from '~/hooks'; import type { PermissionConfig } from '~/components/ui'; const permissions: PermissionConfig[] = [ - { permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_prompts_allow_share' }, - { permission: Permissions.CREATE, labelKey: 'com_ui_prompts_allow_create' }, { permission: Permissions.USE, labelKey: 'com_ui_prompts_allow_use' }, + { permission: Permissions.CREATE, labelKey: 'com_ui_prompts_allow_create' }, + { permission: Permissions.SHARE, labelKey: 'com_ui_prompts_allow_share' }, + { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_prompts_allow_share_public' }, ]; const AdminSettings = () => { diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx index cde24dbc2a..6f575f8577 100644 --- a/client/src/components/Prompts/PromptForm.tsx +++ b/client/src/components/Prompts/PromptForm.tsx @@ -65,7 +65,7 @@ const RightPanel = React.memo( const editorMode = useRecoilValue(store.promptsEditorMode); const hasShareAccess = useHasAccess({ permissionType: PermissionTypes.PROMPTS, - permission: Permissions.SHARED_GLOBAL, + permission: Permissions.SHARE, }); const updateGroupMutation = useUpdatePromptGroup({ diff --git a/client/src/components/Prompts/SharePrompt.tsx b/client/src/components/Prompts/SharePrompt.tsx index aeb9543140..65e8f20f24 100644 --- a/client/src/components/Prompts/SharePrompt.tsx +++ b/client/src/components/Prompts/SharePrompt.tsx @@ -16,10 +16,10 @@ const SharePrompt = React.memo( ({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => { const { user } = useAuthContext(); - // Check if user has permission to share prompts globally + // Check if user has permission to share prompts const hasAccessToSharePrompts = useHasAccess({ permissionType: PermissionTypes.PROMPTS, - permission: Permissions.SHARED_GLOBAL, + permission: Permissions.SHARE, }); // Check user's permissions on this specific promptGroup diff --git a/client/src/components/Sharing/GenericGrantAccessDialog.tsx b/client/src/components/Sharing/GenericGrantAccessDialog.tsx index d3c5547313..d9270ef2d3 100644 --- a/client/src/components/Sharing/GenericGrantAccessDialog.tsx +++ b/client/src/components/Sharing/GenericGrantAccessDialog.tsx @@ -18,6 +18,7 @@ import { usePeoplePickerPermissions, useResourcePermissionState, useCopyToClipboard, + useCanSharePublic, useLocalize, } from '~/hooks'; import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch'; @@ -33,6 +34,7 @@ export default function GenericGrantAccessDialog({ resourceType, onGrantAccess, disabled = false, + buttonClassName, children, }: { resourceDbId?: string | null; @@ -41,15 +43,19 @@ export default function GenericGrantAccessDialog({ resourceType: ResourceType; onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole?: AccessRoleIds) => void; disabled?: boolean; + buttonClassName?: string; children?: React.ReactNode; }) { const localize = useLocalize(); const { showToast } = useToastContext(); - const [isModalOpen, setIsModalOpen] = useState(false); const [isCopying, setIsCopying] = useState(false); - - // Use shared hooks + const [isModalOpen, setIsModalOpen] = useState(false); + const canSharePublic = useCanSharePublic(resourceType); const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions(); + + /** User can use the share dialog if they have people picker access OR can share publicly */ + const canUseShareDialog = hasPeoplePickerAccess || canSharePublic; + const { config, permissionsData, @@ -65,7 +71,7 @@ export default function GenericGrantAccessDialog({ setPublicRole, } = useResourcePermissionState(resourceType, resourceDbId, isModalOpen); - // State for unified list of all shares (existing + newly added) + /** State for unified list of all shares (existing + newly added) */ const [allShares, setAllShares] = useState([]); const [hasChanges, setHasChanges] = useState(false); const [defaultPermissionId, setDefaultPermissionId] = useState( @@ -88,6 +94,11 @@ export default function GenericGrantAccessDialog({ return null; } + // Don't render if user has no useful sharing permissions + if (!canUseShareDialog) { + return null; + } + if (!config) { console.error(`Unsupported resource type: ${resourceType}`); return null; @@ -238,11 +249,11 @@ export default function GenericGrantAccessDialog({ })} type="button" disabled={disabled} - className="h-full" + className={cn('h-9', buttonClassName)} >
- {totalCurrentShares > 0 && (
-
+ {canSharePublic && ( + <> +
- {/* Public Access Section */} - + {/* Public Access Section */} + + + )} {/* Footer Actions */}
diff --git a/client/src/components/Sharing/PeoplePickerAdminSettings.tsx b/client/src/components/Sharing/PeoplePickerAdminSettings.tsx index 7f6530d1f7..38d44ad311 100644 --- a/client/src/components/Sharing/PeoplePickerAdminSettings.tsx +++ b/client/src/components/Sharing/PeoplePickerAdminSettings.tsx @@ -57,9 +57,9 @@ const LabelController: React.FC = ({ render={({ field }) => ( )} diff --git a/client/src/components/SidePanel/Agents/AdminSettings.tsx b/client/src/components/SidePanel/Agents/AdminSettings.tsx index 85ceb1875d..d0872f32aa 100644 --- a/client/src/components/SidePanel/Agents/AdminSettings.tsx +++ b/client/src/components/SidePanel/Agents/AdminSettings.tsx @@ -6,9 +6,10 @@ import { useLocalize } from '~/hooks'; import type { PermissionConfig } from '~/components/ui'; const permissions: PermissionConfig[] = [ - { permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_agents_allow_share' }, - { permission: Permissions.CREATE, labelKey: 'com_ui_agents_allow_create' }, { permission: Permissions.USE, labelKey: 'com_ui_agents_allow_use' }, + { permission: Permissions.CREATE, labelKey: 'com_ui_agents_allow_create' }, + { permission: Permissions.SHARE, labelKey: 'com_ui_agents_allow_share' }, + { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_agents_allow_share_public' }, ]; const AdminSettings = () => { diff --git a/client/src/components/SidePanel/Agents/AgentFooter.tsx b/client/src/components/SidePanel/Agents/AgentFooter.tsx index 7fe90a7cc5..80a449bb2d 100644 --- a/client/src/components/SidePanel/Agents/AgentFooter.tsx +++ b/client/src/components/SidePanel/Agents/AgentFooter.tsx @@ -42,7 +42,7 @@ export default function AgentFooter({ const agent_id = useWatch({ control, name: 'id' }); const hasAccessToShareAgents = useHasAccess({ permissionType: PermissionTypes.AGENTS, - permission: Permissions.SHARED_GLOBAL, + permission: Permissions.SHARE, }); const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( ResourceType.AGENT, diff --git a/client/src/components/SidePanel/MCPBuilder/MCPAdminSettings.tsx b/client/src/components/SidePanel/MCPBuilder/MCPAdminSettings.tsx index dcba33640e..af677999bc 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPAdminSettings.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPAdminSettings.tsx @@ -9,6 +9,7 @@ const permissions: PermissionConfig[] = [ { permission: Permissions.USE, labelKey: 'com_ui_mcp_servers_allow_use' }, { permission: Permissions.CREATE, labelKey: 'com_ui_mcp_servers_allow_create' }, { permission: Permissions.SHARE, labelKey: 'com_ui_mcp_servers_allow_share' }, + { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_mcp_servers_allow_share_public' }, ]; const MCPAdminSettings = () => { diff --git a/client/src/hooks/Sharing/index.ts b/client/src/hooks/Sharing/index.ts index 164cb2c05b..dec520ec3a 100644 --- a/client/src/hooks/Sharing/index.ts +++ b/client/src/hooks/Sharing/index.ts @@ -1,2 +1,3 @@ export { usePeoplePickerPermissions } from './usePeoplePickerPermissions'; export { useResourcePermissionState } from './useResourcePermissionState'; +export { useCanSharePublic } from './useCanSharePublic'; diff --git a/client/src/hooks/Sharing/useCanSharePublic.ts b/client/src/hooks/Sharing/useCanSharePublic.ts new file mode 100644 index 0000000000..699ccc9e73 --- /dev/null +++ b/client/src/hooks/Sharing/useCanSharePublic.ts @@ -0,0 +1,22 @@ +import { ResourceType, PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useHasAccess } from '~/hooks'; + +const resourceToPermissionMap: Record = { + [ResourceType.AGENT]: PermissionTypes.AGENTS, + [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, + [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, +}; + +/** + * Hook to check if a user can share a specific resource type publicly (with everyone) + * @param resourceType The type of resource to check public sharing permission for + * @returns boolean indicating if the user can share the resource publicly + */ +export const useCanSharePublic = (resourceType: ResourceType): boolean => { + const permissionType = resourceToPermissionMap[resourceType]; + const hasAccess = useHasAccess({ + permissionType, + permission: Permissions.SHARE_PUBLIC, + }); + return hasAccess; +}; diff --git a/client/src/hooks/Sharing/usePeoplePickerPermissions.ts b/client/src/hooks/Sharing/usePeoplePickerPermissions.ts index d8579d37a7..be1b6d5bff 100644 --- a/client/src/hooks/Sharing/usePeoplePickerPermissions.ts +++ b/client/src/hooks/Sharing/usePeoplePickerPermissions.ts @@ -4,6 +4,7 @@ import { useHasAccess } from '~/hooks'; /** * Hook to check people picker permissions and return the appropriate type filter + * Note: SHARE_PUBLIC is now per-resource type (AGENTS, PROMPTS, MCP_SERVERS), not on PEOPLE_PICKER * @returns Object with permission states and type filter */ export const usePeoplePickerPermissions = () => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c144e8bda5..aba6ea9fb0 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -699,6 +699,7 @@ "com_ui_agents": "Agents", "com_ui_agents_allow_create": "Allow creating Agents", "com_ui_agents_allow_share": "Allow sharing Agents", + "com_ui_agents_allow_share_public": "Allow sharing Agents publicly", "com_ui_agents_allow_use": "Allow using Agents", "com_ui_all": "all", "com_ui_all_proper": "All", @@ -1088,6 +1089,7 @@ "com_ui_mcp_servers": "MCP Servers", "com_ui_mcp_servers_allow_create": "Allow users to create MCP servers", "com_ui_mcp_servers_allow_share": "Allow users to share MCP servers", + "com_ui_mcp_servers_allow_share_public": "Allow users to share MCP servers publicly", "com_ui_mcp_servers_allow_use": "Allow users to use MCP servers", "com_ui_mcp_title_invalid": "Title can only contain letters, numbers, and spaces", "com_ui_mcp_transport": "Transport", @@ -1207,6 +1209,7 @@ "com_ui_prompts": "Prompts", "com_ui_prompts_allow_create": "Allow creating Prompts", "com_ui_prompts_allow_share": "Allow sharing Prompts", + "com_ui_prompts_allow_share_public": "Allow sharing Prompts publicly", "com_ui_prompts_allow_use": "Allow using Prompts", "com_ui_provider": "Provider", "com_ui_quality": "Quality", diff --git a/librechat.example.yaml b/librechat.example.yaml index 7b7ad9d521..c90ab6592a 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -85,10 +85,16 @@ interface: parameters: true sidePanel: true presets: true - prompts: true + prompts: + use: true + share: false + public: false bookmarks: true multiConvo: true - agents: true + agents: + use: true + share: false + public: false peoplePicker: users: true groups: true @@ -102,9 +108,11 @@ interface: # - use: Allow users to use configured MCP servers # - create: Allow users to create and manage new MCP servers # - share: Allow users to share MCP servers with other users + # - public: Allow users to share MCP servers publicly (with everyone) use: false - create: false share: false + create: false + public: false # Creation / edit MCP server config Dialog config example # trustCheckbox: # label: diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index 9890ad5299..b84ad63498 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -17,11 +17,19 @@ describe('updateInterfacePermissions - permissions', () => { it('should call updateAccessPermissions with the correct parameters when permission types are true', async () => { const config = { interface: { - prompts: true, + prompts: { + use: true, + share: false, + public: false, + }, bookmarks: true, memories: true, multiConvo: true, - agents: true, + agents: { + use: true, + share: false, + public: false, + }, temporaryChat: true, runCode: true, webSearch: true, @@ -35,6 +43,12 @@ describe('updateInterfacePermissions - permissions', () => { marketplace: { use: true, }, + mcpServers: { + use: true, + create: true, + share: false, + public: false, + }, }, }; const configDefaults = { interface: {} } as TConfigDefaults; @@ -50,6 +64,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -62,6 +79,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -78,12 +98,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -96,6 +120,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -111,7 +138,8 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MCP_SERVERS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, - [Permissions.SHARE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; @@ -135,11 +163,19 @@ describe('updateInterfacePermissions - permissions', () => { it('should call updateAccessPermissions with false when permission types are false', async () => { const config = { interface: { - prompts: false, + prompts: { + use: false, + share: false, + public: false, + }, bookmarks: false, memories: false, multiConvo: false, - agents: false, + agents: { + use: false, + share: false, + public: false, + }, temporaryChat: false, runCode: false, webSearch: false, @@ -153,6 +189,12 @@ describe('updateInterfacePermissions - permissions', () => { marketplace: { use: false, }, + mcpServers: { + use: true, + create: true, + share: false, + public: false, + }, }, }; const configDefaults = { interface: {} } as TConfigDefaults; @@ -168,6 +210,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, [PermissionTypes.MEMORIES]: { @@ -180,6 +225,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, @@ -196,12 +244,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: false, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, [PermissionTypes.MEMORIES]: { @@ -214,6 +266,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false }, [PermissionTypes.AGENTS]: { [Permissions.USE]: false, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, @@ -229,7 +284,8 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MCP_SERVERS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, - [Permissions.SHARE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; @@ -286,6 +342,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -298,6 +357,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -314,12 +376,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -332,6 +398,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -348,6 +417,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }; @@ -417,6 +487,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, [PermissionTypes.MEMORIES]: { @@ -429,6 +502,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, @@ -445,12 +521,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false }, [PermissionTypes.MEMORIES]: { @@ -463,6 +543,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: false }, @@ -479,6 +562,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }; @@ -535,6 +619,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -547,6 +634,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -563,12 +653,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true }, [PermissionTypes.MEMORIES]: { @@ -581,6 +675,9 @@ describe('updateInterfacePermissions - permissions', () => { [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true }, [PermissionTypes.AGENTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true }, [PermissionTypes.RUN_CODE]: { [Permissions.USE]: true }, @@ -597,6 +694,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }; @@ -684,6 +782,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; @@ -712,6 +811,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }; @@ -790,6 +890,9 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, // Explicitly configured // All other permissions that don't exist in the database [PermissionTypes.MEMORIES]: { @@ -815,12 +918,16 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }, }; const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, // Explicitly configured // All other permissions that don't exist in the database [PermissionTypes.MEMORIES]: { @@ -846,6 +953,7 @@ describe('updateInterfacePermissions - permissions', () => { [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }; @@ -1016,19 +1124,31 @@ describe('updateInterfacePermissions - permissions', () => { // Check PROMPTS permissions use role defaults expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }); expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }); // Check AGENTS permissions use role defaults expect(userCall[1][PermissionTypes.AGENTS]).toEqual({ [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }); expect(adminCall[1][PermissionTypes.AGENTS]).toEqual({ [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }); // Check MEMORIES permissions use role defaults @@ -1258,6 +1378,9 @@ describe('updateInterfacePermissions - permissions', () => { // Explicitly configured permissions should be updated expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, }); expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({ [Permissions.USE]: true }); @@ -1579,7 +1702,12 @@ describe('updateInterfacePermissions - permissions', () => { // Memory permissions should be updated even though they already exist expect(userCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions); // Prompts should be updated (explicitly configured) - expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true }); + expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, + }); // Bookmarks should be updated (explicitly configured) expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); @@ -1589,7 +1717,12 @@ describe('updateInterfacePermissions - permissions', () => { ); // Memory permissions should be updated even though they already exist expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions); - expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true }); + expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({ + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }); expect(adminCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); // Verify the existing role data was passed to updateAccessPermissions diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index eb93cd4e7d..bfa49e6bbd 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -141,12 +141,52 @@ export async function updateInterfacePermissions({ } }; + // Helper to extract value from boolean or object config + const getConfigUse = ( + config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, + ) => (typeof config === 'boolean' ? config : config?.use); + const getConfigShare = ( + config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, + ) => (typeof config === 'boolean' ? undefined : config?.share); + const getConfigPublic = ( + config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, + ) => (typeof config === 'boolean' ? undefined : config?.public); + + // Get default use values (for backward compat when config is boolean) + const promptsDefaultUse = + typeof defaults.prompts === 'boolean' ? defaults.prompts : defaults.prompts?.use; + const agentsDefaultUse = + typeof defaults.agents === 'boolean' ? defaults.agents : defaults.agents?.use; + const promptsDefaultShare = + typeof defaults.prompts === 'object' ? defaults.prompts?.share : undefined; + const agentsDefaultShare = + typeof defaults.agents === 'object' ? defaults.agents?.share : undefined; + const promptsDefaultPublic = + typeof defaults.prompts === 'object' ? defaults.prompts?.public : undefined; + const agentsDefaultPublic = + typeof defaults.agents === 'object' ? defaults.agents?.public : undefined; + const allPermissions: Partial>> = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: getPermissionValue( - loadedInterface.prompts, + getConfigUse(loadedInterface.prompts), defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE], - defaults.prompts, + promptsDefaultUse, + ), + [Permissions.CREATE]: getPermissionValue( + undefined, + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.CREATE], + true, + ), + [Permissions.SHARE]: getPermissionValue( + getConfigShare(loadedInterface.prompts), + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE], + promptsDefaultShare, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + getConfigPublic(loadedInterface.prompts), + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE_PUBLIC], + promptsDefaultPublic, ), }, [PermissionTypes.BOOKMARKS]: { @@ -194,9 +234,24 @@ export async function updateInterfacePermissions({ }, [PermissionTypes.AGENTS]: { [Permissions.USE]: getPermissionValue( - loadedInterface.agents, + getConfigUse(loadedInterface.agents), defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE], - defaults.agents, + agentsDefaultUse, + ), + [Permissions.CREATE]: getPermissionValue( + undefined, + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.CREATE], + true, + ), + [Permissions.SHARE]: getPermissionValue( + getConfigShare(loadedInterface.agents), + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE], + agentsDefaultShare, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + getConfigPublic(loadedInterface.agents), + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE_PUBLIC], + agentsDefaultPublic, ), }, [PermissionTypes.TEMPORARY_CHAT]: { @@ -274,6 +329,11 @@ export async function updateInterfacePermissions({ defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE], defaults.mcpServers?.share, ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + loadedInterface.mcpServers?.public, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE_PUBLIC], + defaults.mcpServers?.public, + ), }, }; diff --git a/packages/api/src/middleware/access.spec.ts b/packages/api/src/middleware/access.spec.ts index 7731957259..d7ca690c48 100644 --- a/packages/api/src/middleware/access.spec.ts +++ b/packages/api/src/middleware/access.spec.ts @@ -209,7 +209,7 @@ describe('access middleware', () => { permissions: { [PermissionTypes.AGENTS]: { [Permissions.USE]: true, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, }, } as unknown as IRole; @@ -223,9 +223,9 @@ describe('access middleware', () => { const result = await checkAccess({ ...defaultParams, - permissions: [Permissions.USE, Permissions.SHARED_GLOBAL], + permissions: [Permissions.USE, Permissions.SHARE], bodyProps: { - [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], + [Permissions.SHARE]: ['projectIds', 'removeProjectIds'], } as Record, checkObject, }); @@ -237,7 +237,7 @@ describe('access middleware', () => { name: 'user', permissions: { [PermissionTypes.AGENTS]: { - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, }, } as unknown as IRole; @@ -251,9 +251,9 @@ describe('access middleware', () => { const result = await checkAccess({ ...defaultParams, - permissions: [Permissions.SHARED_GLOBAL], + permissions: [Permissions.SHARE], bodyProps: { - [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], + [Permissions.SHARE]: ['projectIds', 'removeProjectIds'], } as Record, checkObject, }); @@ -337,7 +337,7 @@ describe('access middleware', () => { [PermissionTypes.AGENTS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, }, } as unknown as IRole; @@ -350,9 +350,9 @@ describe('access middleware', () => { const middleware = generateCheckAccess({ permissionType: PermissionTypes.AGENTS, - permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL], + permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE], bodyProps: { - [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], + [Permissions.SHARE]: ['projectIds', 'removeProjectIds'], } as Record, getRoleByName: mockGetRoleByName, }); @@ -490,7 +490,7 @@ describe('access middleware', () => { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, [Permissions.CREATE]: true, - [Permissions.SHARED_GLOBAL]: false, + [Permissions.SHARE]: false, }, }, } as unknown as IRole; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d7b44d3c80..ebfcfa93f1 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -587,6 +587,7 @@ const mcpServersSchema = z use: z.boolean().optional(), create: z.boolean().optional(), share: z.boolean().optional(), + public: z.boolean().optional(), trustCheckbox: z .object({ label: localizedStringSchema.optional(), @@ -617,8 +618,26 @@ export const interfaceSchema = z bookmarks: z.boolean().optional(), memories: z.boolean().optional(), presets: z.boolean().optional(), - prompts: z.boolean().optional(), - agents: z.boolean().optional(), + prompts: z + .union([ + z.boolean(), + z.object({ + use: z.boolean().optional(), + share: z.boolean().optional(), + public: z.boolean().optional(), + }), + ]) + .optional(), + agents: z + .union([ + z.boolean(), + z.object({ + use: z.boolean().optional(), + share: z.boolean().optional(), + public: z.boolean().optional(), + }), + ]) + .optional(), temporaryChat: z.boolean().optional(), temporaryChatRetention: z.number().min(1).max(8760).optional(), runCode: z.boolean().optional(), @@ -647,8 +666,16 @@ export const interfaceSchema = z multiConvo: true, bookmarks: true, memories: true, - prompts: true, - agents: true, + prompts: { + use: true, + share: false, + public: false, + }, + agents: { + use: true, + share: false, + public: false, + }, temporaryChat: true, runCode: true, webSearch: true, @@ -664,6 +691,7 @@ export const interfaceSchema = z use: true, create: true, share: false, + public: false, }, fileSearch: true, fileCitations: true, diff --git a/packages/data-provider/src/permissions.ts b/packages/data-provider/src/permissions.ts index b5c90aadeb..0d53dbe29b 100644 --- a/packages/data-provider/src/permissions.ts +++ b/packages/data-provider/src/permissions.ts @@ -62,7 +62,6 @@ export enum PermissionTypes { * Enum for Role-Based Access Control Constants */ export enum Permissions { - SHARED_GLOBAL = 'SHARED_GLOBAL', USE = 'USE', CREATE = 'CREATE', UPDATE = 'UPDATE', @@ -74,13 +73,15 @@ export enum Permissions { VIEW_USERS = 'VIEW_USERS', VIEW_GROUPS = 'VIEW_GROUPS', VIEW_ROLES = 'VIEW_ROLES', + /** Can share resources publicly (with everyone) */ + SHARE_PUBLIC = 'SHARE_PUBLIC', } export const promptPermissionsSchema = z.object({ - [Permissions.SHARED_GLOBAL]: z.boolean().default(false), [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), - // [Permissions.SHARE]: z.boolean().default(false), + [Permissions.SHARE]: z.boolean().default(false), + [Permissions.SHARE_PUBLIC]: z.boolean().default(false), }); export type TPromptPermissions = z.infer; @@ -99,10 +100,10 @@ export const memoryPermissionsSchema = z.object({ export type TMemoryPermissions = z.infer; export const agentPermissionsSchema = z.object({ - [Permissions.SHARED_GLOBAL]: z.boolean().default(false), [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), - // [Permissions.SHARE]: z.boolean().default(false), + [Permissions.SHARE]: z.boolean().default(false), + [Permissions.SHARE_PUBLIC]: z.boolean().default(false), }); export type TAgentPermissions = z.infer; @@ -152,6 +153,7 @@ export const mcpServersPermissionsSchema = z.object({ [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), [Permissions.SHARE]: z.boolean().default(false), + [Permissions.SHARE_PUBLIC]: z.boolean().default(false), }); export type TMcpServersPermissions = z.infer; diff --git a/packages/data-provider/src/roles.ts b/packages/data-provider/src/roles.ts index 02fbeaf6db..b3d44a49d9 100644 --- a/packages/data-provider/src/roles.ts +++ b/packages/data-provider/src/roles.ts @@ -43,10 +43,10 @@ const defaultRolesSchema = z.object({ name: z.literal(SystemRoles.ADMIN), permissions: permissionsSchema.extend({ [PermissionTypes.PROMPTS]: promptPermissionsSchema.extend({ - [Permissions.SHARED_GLOBAL]: z.boolean().default(true), [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), - // [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE_PUBLIC]: z.boolean().default(true), }), [PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), @@ -59,10 +59,10 @@ const defaultRolesSchema = z.object({ [Permissions.OPT_OUT]: z.boolean().default(true), }), [PermissionTypes.AGENTS]: agentPermissionsSchema.extend({ - [Permissions.SHARED_GLOBAL]: z.boolean().default(true), [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), - // [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE_PUBLIC]: z.boolean().default(true), }), [PermissionTypes.MULTI_CONVO]: multiConvoPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), @@ -94,6 +94,7 @@ const defaultRolesSchema = z.object({ [Permissions.USE]: z.boolean().default(true), [Permissions.CREATE]: z.boolean().default(true), [Permissions.SHARE]: z.boolean().default(true), + [Permissions.SHARE_PUBLIC]: z.boolean().default(true), }), }), }), @@ -108,9 +109,10 @@ export const roleDefaults = defaultRolesSchema.parse({ name: SystemRoles.ADMIN, permissions: { [PermissionTypes.PROMPTS]: { - [Permissions.SHARED_GLOBAL]: true, [Permissions.USE]: true, [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true, @@ -123,9 +125,10 @@ export const roleDefaults = defaultRolesSchema.parse({ [Permissions.OPT_OUT]: true, }, [PermissionTypes.AGENTS]: { - [Permissions.SHARED_GLOBAL]: true, [Permissions.USE]: true, [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true, @@ -157,6 +160,7 @@ export const roleDefaults = defaultRolesSchema.parse({ [Permissions.USE]: true, [Permissions.CREATE]: true, [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, }, }, }, diff --git a/packages/data-schemas/src/schema/role.ts b/packages/data-schemas/src/schema/role.ts index e8da248c8d..3ac0a7f8a2 100644 --- a/packages/data-schemas/src/schema/role.ts +++ b/packages/data-schemas/src/schema/role.ts @@ -11,9 +11,10 @@ const rolePermissionsSchema = new Schema( [Permissions.USE]: { type: Boolean }, }, [PermissionTypes.PROMPTS]: { - [Permissions.SHARED_GLOBAL]: { type: Boolean }, [Permissions.USE]: { type: Boolean }, [Permissions.CREATE]: { type: Boolean }, + [Permissions.SHARE]: { type: Boolean }, + [Permissions.SHARE_PUBLIC]: { type: Boolean }, }, [PermissionTypes.MEMORIES]: { [Permissions.USE]: { type: Boolean }, @@ -23,9 +24,10 @@ const rolePermissionsSchema = new Schema( [Permissions.OPT_OUT]: { type: Boolean }, }, [PermissionTypes.AGENTS]: { - [Permissions.SHARED_GLOBAL]: { type: Boolean }, [Permissions.USE]: { type: Boolean }, [Permissions.CREATE]: { type: Boolean }, + [Permissions.SHARE]: { type: Boolean }, + [Permissions.SHARE_PUBLIC]: { type: Boolean }, }, [PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: { type: Boolean }, @@ -57,6 +59,7 @@ const rolePermissionsSchema = new Schema( [Permissions.USE]: { type: Boolean }, [Permissions.CREATE]: { type: Boolean }, [Permissions.SHARE]: { type: Boolean }, + [Permissions.SHARE_PUBLIC]: { type: Boolean }, }, }, { _id: false }, diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index 679e80010f..fc6340da2c 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -10,9 +10,10 @@ export interface IRole extends Document { [Permissions.USE]?: boolean; }; [PermissionTypes.PROMPTS]?: { - [Permissions.SHARED_GLOBAL]?: boolean; [Permissions.USE]?: boolean; [Permissions.CREATE]?: boolean; + [Permissions.SHARE]?: boolean; + [Permissions.SHARE_PUBLIC]?: boolean; }; [PermissionTypes.MEMORIES]?: { [Permissions.USE]?: boolean; @@ -21,9 +22,10 @@ export interface IRole extends Document { [Permissions.READ]?: boolean; }; [PermissionTypes.AGENTS]?: { - [Permissions.SHARED_GLOBAL]?: boolean; [Permissions.USE]?: boolean; [Permissions.CREATE]?: boolean; + [Permissions.SHARE]?: boolean; + [Permissions.SHARE_PUBLIC]?: boolean; }; [PermissionTypes.MULTI_CONVO]?: { [Permissions.USE]?: boolean; @@ -55,6 +57,7 @@ export interface IRole extends Document { [Permissions.USE]?: boolean; [Permissions.CREATE]?: boolean; [Permissions.SHARE]?: boolean; + [Permissions.SHARE_PUBLIC]?: boolean; }; }; } From 200377947e2a7bf2de69586548a312ff44025f18 Mon Sep 17 00:00:00 2001 From: Karthikeyan N Date: Sun, 11 Jan 2026 00:56:19 +0530 Subject: [PATCH 008/282] =?UTF-8?q?=F0=9F=8C=99=20feat:=20Add=20Moonshot?= =?UTF-8?q?=20Kimi=20K2=20Bedrock=20Support=20(#11288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bedrock): add Moonshot Kimi K2 Thinking model support - Add Moonshot provider to BedrockProviders enum - Add Moonshot-specific parameter settings with 16384 default max tokens - Add conditional for anthropic_beta to only apply to Anthropic models - Kimi K2 Thinking model: moonshot.kimi-k2-thinking (256K context) * Delete add-kimi-bedrock.md * Remove comment on anthropic_beta condition Remove comment about adding anthropic_beta for Anthropic models. * chore: enum order * feat(bedrock): add tests to ensure anthropic_beta is not added to Moonshot Kimi K2 and DeepSeek models --------- Co-authored-by: Danny Avila Co-authored-by: Danny Avila --- packages/data-provider/specs/bedrock.spec.ts | 22 +++++++++++ packages/data-provider/src/bedrock.ts | 4 +- .../data-provider/src/parameterSettings.ts | 39 +++++++++++++++++++ packages/data-provider/src/schemas.ts | 3 +- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index c569a1b5a7..2a0de6937a 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -88,6 +88,28 @@ describe('bedrockInputParser', () => { expect(result.additionalModelRequestFields).toBeUndefined(); }); + test('should not add anthropic_beta to Moonshot Kimi K2 models', () => { + const input = { + model: 'moonshot.kimi-k2-0711-thinking', + }; + const result = bedrockInputParser.parse(input) as BedrockConverseInput; + const additionalFields = result.additionalModelRequestFields as + | Record + | undefined; + expect(additionalFields?.anthropic_beta).toBeUndefined(); + }); + + test('should not add anthropic_beta to DeepSeek models', () => { + const input = { + model: 'deepseek.deepseek-r1', + }; + const result = bedrockInputParser.parse(input) as BedrockConverseInput; + const additionalFields = result.additionalModelRequestFields as + | Record + | undefined; + expect(additionalFields?.anthropic_beta).toBeUndefined(); + }); + test('should respect explicit thinking configuration', () => { const input = { model: 'anthropic.claude-sonnet-4', diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index 2a4184729f..b37fdc25e1 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -137,7 +137,9 @@ export const bedrockInputParser = s.tConversationSchema if (additionalFields.thinking === true && additionalFields.thinkingBudget === undefined) { additionalFields.thinkingBudget = 2000; } - additionalFields.anthropic_beta = ['output-128k-2025-02-19']; + if (typedData.model.includes('anthropic.')) { + additionalFields.anthropic_beta = ['output-128k-2025-02-19']; + } } else if (additionalFields.thinking != null || additionalFields.thinkingBudget != null) { delete additionalFields.thinking; delete additionalFields.thinkingBudget; diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 3a9425bca1..ae811d3d5b 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -880,6 +880,40 @@ const bedrockGeneralCol2: SettingsConfiguration = [ librechat.fileTokenLimit, ]; +const bedrockMoonshot: SettingsConfiguration = [ + librechat.modelLabel, + bedrock.system, + librechat.maxContextTokens, + createDefinition(bedrock.maxTokens, { + default: 16384, + }), + bedrock.temperature, + bedrock.topP, + baseDefinitions.stop, + librechat.resendFiles, + bedrock.region, + librechat.fileTokenLimit, +]; + +const bedrockMoonshotCol1: SettingsConfiguration = [ + baseDefinitions.model as SettingDefinition, + librechat.modelLabel, + bedrock.system, + baseDefinitions.stop, +]; + +const bedrockMoonshotCol2: SettingsConfiguration = [ + librechat.maxContextTokens, + createDefinition(bedrock.maxTokens, { + default: 16384, + }), + bedrock.temperature, + bedrock.topP, + librechat.resendFiles, + bedrock.region, + librechat.fileTokenLimit, +]; + export const paramSettings: Record = { [EModelEndpoint.openAI]: openAI, [EModelEndpoint.azureOpenAI]: openAI, @@ -892,6 +926,7 @@ export const paramSettings: Record = [`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneral, [`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneral, [`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneral, + [`${EModelEndpoint.bedrock}-${BedrockProviders.Moonshot}`]: bedrockMoonshot, [EModelEndpoint.google]: googleConfig, }; @@ -936,6 +971,10 @@ export const presetSettings: Record< [`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneralColumns, [`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneralColumns, [`${EModelEndpoint.bedrock}-${BedrockProviders.DeepSeek}`]: bedrockGeneralColumns, + [`${EModelEndpoint.bedrock}-${BedrockProviders.Moonshot}`]: { + col1: bedrockMoonshotCol1, + col2: bedrockMoonshotCol2, + }, [EModelEndpoint.google]: { col1: googleCol1, col2: googleCol2, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index aea1a9bf09..a3378d3340 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -94,10 +94,11 @@ export enum BedrockProviders { Amazon = 'amazon', Anthropic = 'anthropic', Cohere = 'cohere', + DeepSeek = 'deepseek', Meta = 'meta', MistralAI = 'mistral', + Moonshot = 'moonshot', StabilityAI = 'stability', - DeepSeek = 'deepseek', } export const getModelKey = (endpoint: EModelEndpoint | string, model: string) => { From 2958fcd0c501c9f62ad32e0e34cc79b5fca608ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:34:09 -0500 Subject: [PATCH 009/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/ja/translation.json | 146 ++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 3 deletions(-) diff --git a/client/src/locales/ja/translation.json b/client/src/locales/ja/translation.json index 1bb2d9fa0e..1438bf8c30 100644 --- a/client/src/locales/ja/translation.json +++ b/client/src/locales/ja/translation.json @@ -8,6 +8,7 @@ "com_agents_all": "ć™ć¹ć¦ć®ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆ", "com_agents_all_category": "全て", "com_agents_all_description": "ć™ć¹ć¦ć®ć‚«ćƒ†ć‚“ćƒŖć®å…±ęœ‰ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć‚’å‚ē…§", + "com_agents_avatar_upload_error": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć®ć‚¢ćƒć‚æćƒ¼ć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰ć§ćć¾ć›ć‚“ć§ć—ćŸ", "com_agents_by_librechat": "LibreChat悈悊", "com_agents_category_aftersales": "ć‚¢ćƒ•ć‚æćƒ¼ć‚»ćƒ¼ćƒ«ć‚¹", "com_agents_category_aftersales_description": "č²©å£²å¾Œć®ć‚µćƒćƒ¼ćƒˆć€ćƒ”ćƒ³ćƒ†ćƒŠćƒ³ć‚¹ć€é”§å®¢ć‚µćƒ¼ćƒ“ć‚¹ć«ē‰¹åŒ–ć—ćŸć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆ", @@ -34,6 +35,7 @@ "com_agents_copy_link": "ćƒŖćƒ³ć‚Æć‚’ć‚³ćƒ”ćƒ¼", "com_agents_create_error": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć®ä½œęˆäø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸć€‚", "com_agents_created_by": "by", + "com_agents_description_card": "čŖ¬ę˜Žļ¼š {{description}}", "com_agents_description_placeholder": "ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³: ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć®čŖ¬ę˜Žć‚’å…„åŠ›ć—ć¦ćć ć•ć„", "com_agents_empty_state_heading": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆćŒč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“", "com_agents_enable_file_search": "ćƒ•ć‚”ć‚¤ćƒ«ę¤œē“¢ć‚’ęœ‰åŠ¹ć«ć™ć‚‹", @@ -142,6 +144,7 @@ "com_assistants_update_actions_success": "ć‚¢ć‚Æć‚·ćƒ§ćƒ³ćŒä½œęˆć¾ćŸćÆę›“ę–°ć•ć‚Œć¾ć—ćŸ", "com_assistants_update_error": "ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć®ę›“ę–°äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸć€‚", "com_assistants_update_success": "ć‚¢ćƒƒćƒ—ćƒ‡ćƒ¼ćƒˆć«ęˆåŠŸć—ć¾ć—ćŸ", + "com_assistants_update_success_name": "ę­£åøøć«ę›“ę–°ć•ć‚Œć¾ć—ćŸ {{name}}", "com_auth_already_have_account": "ę—¢ć«ć‚¢ć‚«ć‚¦ćƒ³ćƒˆćŒć‚ć‚‹å “åˆćÆć“ć”ć‚‰", "com_auth_apple_login": "Appleć§ć‚µć‚¤ćƒ³ć‚¤ćƒ³", "com_auth_back_to_login": "ćƒ­ć‚°ć‚¤ćƒ³ē”»é¢ć«ęˆ»ć‚‹", @@ -311,6 +314,7 @@ "com_endpoint_preset_default_removed": "ćŒē„”åŠ¹åŒ–ć•ć‚Œć¾ć—ćŸć€‚", "com_endpoint_preset_delete_confirm": "ęœ¬å½“ć«ć“ć®ćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’å‰Šé™¤ć—ć¾ć™ć‹ļ¼Ÿ", "com_endpoint_preset_delete_error": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć®å‰Šé™¤ć«å¤±ę•—ć—ć¾ć—ćŸć€‚ć‚‚ć†äø€åŗ¦ćŠč©¦ć—äø‹ć•ć„ć€‚", + "com_endpoint_preset_delete_success": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’å‰Šé™¤ć—ć¾ć—ćŸ", "com_endpoint_preset_import": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć®ć‚¤ćƒ³ćƒćƒ¼ćƒˆćŒå®Œäŗ†ć—ć¾ć—ćŸ", "com_endpoint_preset_import_error": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć®ć‚¤ćƒ³ćƒćƒ¼ćƒˆć«å¤±ę•—ć—ć¾ć—ćŸć€‚ć‚‚ć†äø€åŗ¦ćŠč©¦ć—äø‹ć•ć„ć€‚", "com_endpoint_preset_name": "ćƒ—ćƒŖć‚»ćƒƒćƒˆå", @@ -377,6 +381,7 @@ "com_files_no_results": "ēµęžœćŒć‚ć‚Šć¾ć›ć‚“ć€‚", "com_files_number_selected": "{{0}} of {{1}} ćƒ•ć‚”ć‚¤ćƒ«ćŒéøęŠžć•ć‚Œć¾ć—ćŸ", "com_files_preparing_download": "ćƒ€ć‚¦ćƒ³ćƒ­ćƒ¼ćƒ‰ć®ęŗ–å‚™...", + "com_files_results_found": "{{count}} ä»¶ć®ēµęžœćŒč¦‹ć¤ć‹ć‚Šć¾ć—ćŸ", "com_files_sharepoint_picker_title": "ćƒ•ć‚”ć‚¤ćƒ«ć‚’éøęŠž", "com_files_table": "ć“ć“ć«ä½•ć‹ć‚’å…„ć‚Œć‚‹åæ…č¦ćŒć‚ć‚Šć¾ć™ć€‚ē©ŗć§ć—ćŸ", "com_files_upload_local_machine": "ćƒ­ćƒ¼ć‚«ćƒ«ć‚³ćƒ³ćƒ”ćƒ„ćƒ¼ć‚æć‹ć‚‰", @@ -427,6 +432,7 @@ "com_nav_chat_commands": "ćƒćƒ£ćƒƒćƒˆć‚³ćƒžćƒ³ćƒ‰", "com_nav_chat_commands_info": "ćƒ”ćƒƒć‚»ćƒ¼ć‚øć®å…ˆé ­ć«ē‰¹å®šć®ę–‡å­—ć‚’å…„åŠ›ć™ć‚‹ć“ćØć§ć€ć“ć‚Œć‚‰ć®ć‚³ćƒžćƒ³ćƒ‰ćŒęœ‰åŠ¹ć«ćŖć‚Šć¾ć™ć€‚å„ć‚³ćƒžćƒ³ćƒ‰ćÆć€ę±ŗć‚ć‚‰ć‚ŒćŸę–‡å­—ļ¼ˆćƒ—ćƒ¬ćƒ•ć‚£ćƒƒć‚Æć‚¹ļ¼‰ć§čµ·å‹•ć—ć¾ć™ć€‚ćƒ”ćƒƒć‚»ćƒ¼ć‚øć®å…ˆé ­ć«ć“ć‚Œć‚‰ć®ę–‡å­—ć‚’ć‚ˆćä½æē”Øć™ć‚‹å “åˆćÆć€ć‚³ćƒžćƒ³ćƒ‰ę©Ÿčƒ½ć‚’ē„”åŠ¹ć«ć™ć‚‹ć“ćØćŒć§ćć¾ć™ć€‚", "com_nav_chat_direction": "ćƒćƒ£ćƒƒćƒˆć®ę–¹å‘", + "com_nav_chat_direction_selected": "ćƒćƒ£ćƒƒćƒˆć®ę–¹å‘: {{direction}}", "com_nav_clear_all_chats": "ć™ć¹ć¦ć®ćƒćƒ£ćƒƒćƒˆć‚’å‰Šé™¤ć™ć‚‹", "com_nav_clear_cache_confirm_message": "ć‚­ćƒ£ćƒƒć‚·ćƒ„ć‚’å‰Šé™¤ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹ļ¼Ÿ", "com_nav_clear_conversation": "ä¼šč©±ć‚’å‰Šé™¤ć™ć‚‹", @@ -434,9 +440,11 @@ "com_nav_close_sidebar": "ć‚µć‚¤ćƒ‰ćƒćƒ¼ć‚’é–‰ć˜ć‚‹", "com_nav_commands": "Commands", "com_nav_confirm_clear": "å‰Šé™¤ć‚’ē¢ŗå®š", + "com_nav_control_panel": "ć‚³ćƒ³ćƒˆćƒ­ćƒ¼ćƒ«ćƒ‘ćƒćƒ«", "com_nav_conversation_mode": "ä¼šč©±ćƒ¢ćƒ¼ćƒ‰", "com_nav_convo_menu_options": "ä¼šč©±ćƒ”ćƒ‹ćƒ„ćƒ¼ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³", "com_nav_db_sensitivity": "ćƒ‡ć‚·ćƒ™ćƒ«ę„Ÿåŗ¦", + "com_nav_default_temporary_chat": "äø€ę™‚ćƒćƒ£ćƒƒćƒˆć‚’ćƒ‡ćƒ•ć‚©ćƒ«ćƒˆć«ć™ć‚‹", "com_nav_delete_account": "ć‚¢ć‚«ć‚¦ćƒ³ćƒˆć‚’å‰Šé™¤", "com_nav_delete_account_button": "ć‚¢ć‚«ć‚¦ćƒ³ćƒˆć‚’å®Œå…Øć«å‰Šé™¤ć™ć‚‹", "com_nav_delete_account_confirm": "ć‚¢ć‚«ć‚¦ćƒ³ćƒˆć‚’å‰Šé™¤ć—ć¾ć™ć‹?", @@ -470,6 +478,7 @@ "com_nav_info_code_artifacts": "ćƒćƒ£ćƒƒćƒˆć®ęØŖć«å®ŸéØ“ēš„ćŖć‚³ćƒ¼ćƒ‰ ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆć®č”Øē¤ŗć‚’ęœ‰åŠ¹ć«ć—ć¾ć™", "com_nav_info_code_artifacts_agent": "ć“ć®ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć®ć‚³ćƒ¼ćƒ‰ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆć®ä½æē”Øć‚’ęœ‰åŠ¹ć«ć—ć¾ć™ć€‚ćƒ‡ćƒ•ć‚©ćƒ«ćƒˆć§ćÆć€\"ć‚«ć‚¹ć‚æćƒ ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćƒ¢ćƒ¼ćƒ‰\" ćŒęœ‰åŠ¹ć«ćŖć£ć¦ć„ćŖć„é™ć‚Šć€ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆć®ä½æē”Øć«ē‰¹åŒ–ć—ćŸčæ½åŠ ć®ęŒ‡ē¤ŗćŒčæ½åŠ ć•ć‚Œć¾ć™ć€‚", "com_nav_info_custom_prompt_mode": "ęœ‰åŠ¹ć«ć™ć‚‹ćØć€ćƒ‡ćƒ•ć‚©ćƒ«ćƒˆć®ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆ ć‚·ć‚¹ćƒ†ćƒ  ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćÆå«ć¾ć‚Œć¾ć›ć‚“ć€‚ć“ć®ćƒ¢ćƒ¼ćƒ‰ć§ćÆć€ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆē”ŸęˆęŒ‡ē¤ŗć‚’ć™ć¹ć¦ę‰‹å‹•ć§ęä¾›ć™ć‚‹åæ…č¦ćŒć‚ć‚Šć¾ć™ć€‚", + "com_nav_info_default_temporary_chat": "ęœ‰åŠ¹ć«ć™ć‚‹ćØć€ćƒ‡ćƒ•ć‚©ćƒ«ćƒˆć§äø€ę™‚ćƒćƒ£ćƒƒćƒˆćƒ¢ćƒ¼ćƒ‰ćŒęœ‰åŠ¹ćŖēŠ¶ę…‹ć§ę–°č¦ćƒćƒ£ćƒƒćƒˆćŒé–‹å§‹ć•ć‚Œć¾ć™ć€‚äø€ę™‚ēš„ćŖćƒćƒ£ćƒƒćƒˆćÆå±„ę­“ć«äæå­˜ć•ć‚Œć¾ć›ć‚“ć€‚", "com_nav_info_enter_to_send": "ęœ‰åŠ¹ć«ćŖć£ć¦ć„ć‚‹å “åˆć€ `ENTER` ć‚­ćƒ¼ć‚’ęŠ¼ć™ćØćƒ”ćƒƒć‚»ćƒ¼ć‚øćŒé€äæ”ć•ć‚Œć¾ć™ć€‚ē„”åŠ¹ć«ćŖć£ć¦ć„ć‚‹å “åˆć€Enterć‚­ćƒ¼ć‚’ęŠ¼ć™ćØę–°ć—ć„č”ŒćŒčæ½åŠ ć•ć‚Œć€ `CTRL + ENTER` / `⌘ + ENTER` ć‚­ćƒ¼ć‚’ęŠ¼ć—ć¦ćƒ”ćƒƒć‚»ćƒ¼ć‚øć‚’é€äæ”ć™ć‚‹åæ…č¦ćŒć‚ć‚Šć¾ć™ć€‚", "com_nav_info_fork_change_default": "`č”Øē¤ŗćƒ”ćƒƒć‚»ćƒ¼ć‚øć®ćæ` ćÆć€éøęŠžć—ćŸćƒ”ćƒƒć‚»ćƒ¼ć‚øćøć®ē›“ęŽ„ćƒ‘ć‚¹ć®ćæćŒå«ć¾ć‚Œć¾ć™ć€‚ `é–¢é€£ćƒ–ćƒ©ćƒ³ćƒć‚’å«ć‚ć‚‹` ćÆć€ćƒ‘ć‚¹ć«ę²æć£ćŸćƒ–ćƒ©ćƒ³ćƒć‚’čæ½åŠ ć—ć¾ć™ć€‚ `すべてを対豔に含める` ćÆć€ęŽ„ē¶šć•ć‚Œć¦ć„ć‚‹ć™ć¹ć¦ć®ćƒ”ćƒƒć‚»ćƒ¼ć‚øćØćƒ–ćƒ©ćƒ³ćƒć‚’å«ćæć¾ć™ć€‚", "com_nav_info_fork_split_target_setting": "ęœ‰åŠ¹ć«ćŖć£ć¦ć„ć‚‹å “åˆć€éøęŠžć—ćŸå‹•ä½œć«å¾“ć£ć¦ć€åÆ¾č±”ćƒ”ćƒƒć‚»ćƒ¼ć‚øć‹ć‚‰ä¼šč©±å†…ć®ęœ€ę–°ćƒ”ćƒƒć‚»ćƒ¼ć‚øć¾ć§åˆ†å²ćŒé–‹å§‹ć•ć‚Œć¾ć™ć€‚", @@ -524,6 +533,7 @@ "com_nav_long_audio_warning": "é•·ć„ćƒ†ć‚­ć‚¹ćƒˆć®å‡¦ē†ć«ćÆę™‚é–“ćŒć‹ć‹ć‚Šć¾ć™ć€‚", "com_nav_maximize_chat_space": "ćƒćƒ£ćƒƒćƒˆē”»é¢ć‚’ęœ€å¤§åŒ–", "com_nav_mcp_configure_server": "{{0}}ć‚’čØ­å®š", + "com_nav_mcp_status_connected": "ęŽ„ē¶šęøˆćæ", "com_nav_mcp_status_connecting": "{{0}} - ęŽ„ē¶šäø­", "com_nav_mcp_vars_update_error": "MCP ć‚«ć‚¹ć‚æćƒ  ćƒ¦ćƒ¼ć‚¶ćƒ¼å¤‰ę•°ć®ę›“ę–°äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸ", "com_nav_mcp_vars_updated": "MCP ć‚«ć‚¹ć‚æćƒ ćƒ¦ćƒ¼ć‚¶ćƒ¼å¤‰ę•°ćŒę­£åøøć«ę›“ę–°ć•ć‚Œć¾ć—ćŸć€‚", @@ -563,6 +573,7 @@ "com_nav_theme_dark": "ćƒ€ćƒ¼ć‚Æ", "com_nav_theme_light": "ćƒ©ć‚¤ćƒˆ", "com_nav_theme_system": "ć‚·ć‚¹ćƒ†ćƒ ", + "com_nav_toggle_sidebar": "ć‚µć‚¤ćƒ‰ćƒćƒ¼ć®åˆ‡ć‚Šę›æćˆ", "com_nav_tool_dialog": "ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆćƒ„ćƒ¼ćƒ«", "com_nav_tool_dialog_agents": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆćƒ„ćƒ¼ćƒ«", "com_nav_tool_dialog_description": "ćƒ„ćƒ¼ćƒ«ć®éøęŠžć‚’ē¶­ęŒć™ć‚‹ć«ćÆć€ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć‚’äæå­˜ć™ć‚‹åæ…č¦ćŒć‚ć‚Šć¾ć™ć€‚", @@ -613,14 +624,22 @@ "com_ui_action_button": "ć‚¢ć‚Æć‚·ćƒ§ćƒ³ćƒœć‚æćƒ³", "com_ui_active": "ęœ‰åŠ¹åŒ–", "com_ui_add": "追加", + "com_ui_add_code_interpreter_api_key": "Code Interpreter APIć‚­ćƒ¼ć‚’čæ½åŠ ", + "com_ui_add_first_bookmark": "ćƒćƒ£ćƒƒćƒˆć‚’čæ½åŠ ć™ć‚‹ć«ćÆć‚ÆćƒŖćƒƒć‚Æć—ć¦ćć ć•ć„", + "com_ui_add_first_mcp_server": "ęœ€åˆć®MCPć‚µćƒ¼ćƒćƒ¼ć‚’ä½œęˆć—ć¦å§‹ć‚ć¾ć—ć‚‡ć†", + "com_ui_add_first_prompt": "å§‹ć‚ć‚‹ć«ćÆćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’ä½œęˆć—ć¦ćć ć•ć„", "com_ui_add_mcp": "MCPの追加", "com_ui_add_mcp_server": "MCPć‚µćƒ¼ćƒćƒ¼ć®čæ½åŠ ", "com_ui_add_model_preset": "čæ½åŠ ć®åæœē­”ć®ćŸć‚ć®ćƒ¢ćƒ‡ćƒ«ć¾ćŸćÆćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’čæ½åŠ ć™ć‚‹", "com_ui_add_multi_conversation": "č¤‡ę•°ć®ćƒćƒ£ćƒƒćƒˆć‚’čæ½åŠ ", + "com_ui_add_special_variables": "ē‰¹åˆ„ćŖå¤‰ę•°ć‚’čæ½åŠ ", + "com_ui_add_web_search_api_keys": "Web検瓢APIć‚­ćƒ¼ć‚’čæ½åŠ ć™ć‚‹", "com_ui_adding_details": "č©³ē“°ć‚’čæ½åŠ ć™ć‚‹", + "com_ui_additional_details": "追加の詳瓰", "com_ui_admin": "箔理者", "com_ui_admin_access_warning": "ē®”ē†č€…ć‚¢ć‚Æć‚»ć‚¹ć‚’ć“ć®ę©Ÿčƒ½ć§ē„”åŠ¹ć«ć™ć‚‹ćØć€äŗˆęœŸć›ć¬UIäøŠć®å•é”ŒćŒē™ŗē”Ÿć—ć€ē”»é¢ć®å†čŖ­ćæč¾¼ćæćŒåæ…č¦ć«ćŖć‚‹å “åˆćŒć‚ć‚Šć¾ć™ć€‚čØ­å®šć‚’äæå­˜ć—ćŸå “åˆć€å…ƒć«ęˆ»ć™ć«ćÆ librechat.yaml ć®čØ­å®šćƒ•ć‚”ć‚¤ćƒ«ć‚’ē›“ęŽ„ē·Øé›†ć™ć‚‹åæ…č¦ćŒć‚ć‚Šć€ć“ć®å¤‰ę›“ćÆć™ć¹ć¦ć®ęØ©é™ć«å½±éŸæć—ć¾ć™ć€‚", "com_ui_admin_settings": "ē®”ē†č€…čØ­å®š", + "com_ui_admin_settings_section": "ē®”ē†č€…čØ­å®š - {{section}}", "com_ui_advanced": "高度", "com_ui_advanced_settings": "詳瓰設定", "com_ui_agent": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆ", @@ -741,6 +760,10 @@ "com_ui_bookmarks_title": "ć‚æć‚¤ćƒˆćƒ«", "com_ui_bookmarks_update_error": "ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚Æć®ę›“ę–°äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸ", "com_ui_bookmarks_update_success": "ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚ÆćŒę­£åøøć«ę›“ę–°ć•ć‚Œć¾ć—ćŸ", + "com_ui_branch_created": "ćƒ–ćƒ©ćƒ³ćƒćŒę­£åøøć«ä½œęˆć•ć‚Œć¾ć—ćŸ", + "com_ui_branch_error": "åˆ†å²ć®ä½œęˆć«å¤±ę•—ć—ć¾ć—ćŸ", + "com_ui_branch_message": "ć“ć®åæœē­”ć‹ć‚‰åˆ†å²ć‚’ä½œęˆć™ć‚‹", + "com_ui_by_author": "by {{0}}", "com_ui_callback_url": "ć‚³ćƒ¼ćƒ«ćƒćƒƒć‚ÆURL", "com_ui_cancel": "ć‚­ćƒ£ćƒ³ć‚»ćƒ«", "com_ui_cancelled": "ć‚­ćƒ£ćƒ³ć‚»ćƒ«", @@ -748,21 +771,31 @@ "com_ui_change_version": "ćƒćƒ¼ć‚øćƒ§ćƒ³å¤‰ę›“", "com_ui_chat": "チャット", "com_ui_chat_history": "チャット屄歓", + "com_ui_chats": "チャット", + "com_ui_check_internet": "ć‚¤ćƒ³ć‚æćƒ¼ćƒćƒƒćƒˆęŽ„ē¶šć‚’ē¢ŗčŖć—ć¦ćć ć•ć„", "com_ui_clear": "å‰Šé™¤ć™ć‚‹", "com_ui_clear_all": "ć™ć¹ć¦ć‚ÆćƒŖć‚¢", + "com_ui_clear_browser_cache": "ćƒ–ćƒ©ć‚¦ć‚¶ć®ć‚­ćƒ£ćƒƒć‚·ćƒ„ć‚’ć‚ÆćƒŖć‚¢ć—ć¦äø‹ć•ć„", + "com_ui_clear_presets": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’ć‚ÆćƒŖć‚¢", + "com_ui_clear_search": "ę¤œē“¢ć‚’ć‚ÆćƒŖć‚¢", "com_ui_click_to_close": "ć‚ÆćƒŖćƒƒć‚Æć—ć¦é–‰ć˜ć‚‹", + "com_ui_click_to_view_var": "ć‚ÆćƒŖćƒƒć‚Æć—ć¦č”Øē¤ŗ {{0}}", "com_ui_client_id": "ć‚Æćƒ©ć‚¤ć‚¢ćƒ³ćƒˆID", "com_ui_client_secret": "ć‚Æćƒ©ć‚¤ć‚¢ćƒ³ćƒˆć‚·ćƒ¼ć‚Æćƒ¬ćƒƒćƒˆ", "com_ui_close": "閉恘悋", "com_ui_close_menu": "ćƒ”ćƒ‹ćƒ„ćƒ¼ć‚’é–‰ć˜ć‚‹", "com_ui_close_settings": "čØ­å®šć‚’é–‰ć˜ć‚‹", + "com_ui_close_var": "閉恘悋 {{0}}", "com_ui_close_window": "ć‚¦ć‚£ćƒ³ćƒ‰ć‚¦ć‚’é–‰ć˜ć‚‹", "com_ui_code": "ć‚³ćƒ¼ćƒ‰", + "com_ui_collapse": "ꊘ悊恟恟悀", "com_ui_collapse_chat": "ćƒćƒ£ćƒƒćƒˆć‚’ęŠ˜ć‚ŠćŸćŸć‚€", + "com_ui_collapse_thoughts": "ꀝ考悒ꊘ悊恟恟悀", "com_ui_command_placeholder": "ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³:ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć®ć‚³ćƒžćƒ³ćƒ‰ć¾ćŸćÆåå‰ć‚’å…„åŠ›", "com_ui_command_usage_placeholder": "ć‚³ćƒžćƒ³ćƒ‰ć¾ćŸćÆåå‰ć§ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’éøęŠžć—ć¦ćć ć•ć„", "com_ui_complete_setup": "ć‚»ćƒƒćƒˆć‚¢ćƒƒćƒ—å®Œäŗ†", "com_ui_concise": "簔潔", + "com_ui_configure": "設定", "com_ui_configure_mcp_variables_for": "{{0}}ć®å¤‰ę•°ć‚’čØ­å®š", "com_ui_confirm": "ē¢ŗčŖ", "com_ui_confirm_action": "å®Ÿč”Œć™ć‚‹", @@ -770,13 +803,20 @@ "com_ui_confirm_change": "å¤‰ę›“ć®ē¢ŗčŖ", "com_ui_connecting": "ęŽ„ē¶šäø­", "com_ui_context": "ć‚³ćƒ³ćƒ†ć‚­ć‚¹ćƒˆ", + "com_ui_context_filter_sort": "ć‚³ćƒ³ćƒ†ć‚­ć‚¹ćƒˆć«ć‚ˆć‚‹ćƒ•ć‚£ćƒ«ć‚æćƒ¼ćØäø¦ć¹ę›æćˆ", "com_ui_continue": "ē¶šć‘ć‚‹", "com_ui_continue_oauth": "OAuthで続蔌", + "com_ui_control_bar": "ć‚³ćƒ³ćƒˆćƒ­ćƒ¼ćƒ«ćƒćƒ¼", "com_ui_controls": "箔理", + "com_ui_conversation": "会話", + "com_ui_conversation_label": "{{title}} 会話", + "com_ui_conversations": "会話", + "com_ui_convo_archived": "ä¼šč©±ćÆć‚¢ćƒ¼ć‚«ć‚¤ćƒ–ć•ć‚Œć¾ć—ćŸ", "com_ui_convo_delete_error": "ä¼šč©±ć®å‰Šé™¤ć«å¤±ę•—ć—ć¾ć—ćŸ", "com_ui_convo_delete_success": "ä¼šč©±ć®å‰Šé™¤ć«ęˆåŠŸ", "com_ui_copied": "ć‚³ćƒ”ćƒ¼ć—ć¾ć—ćŸļ¼", "com_ui_copied_to_clipboard": "ć‚³ćƒ”ćƒ¼ć—ć¾ć—ćŸ", + "com_ui_copy": "ć‚³ćƒ”ćƒ¼", "com_ui_copy_code": "ć‚³ćƒ¼ćƒ‰ć‚’ć‚³ćƒ”ćƒ¼ć™ć‚‹", "com_ui_copy_link": "ćƒŖćƒ³ć‚Æć‚’ć‚³ćƒ”ćƒ¼", "com_ui_copy_stack_trace": "ć‚¹ć‚æćƒƒć‚Æćƒˆćƒ¬ćƒ¼ć‚¹ć‚’ć‚³ćƒ”ćƒ¼ć™ć‚‹", @@ -784,15 +824,19 @@ "com_ui_copy_to_clipboard": "ć‚ÆćƒŖćƒƒćƒ—ćƒœćƒ¼ćƒ‰ćøć‚³ćƒ”ćƒ¼", "com_ui_copy_url_to_clipboard": "URLć‚’ć‚ÆćƒŖćƒƒćƒ—ćƒœćƒ¼ćƒ‰ć«ć‚³ćƒ”ćƒ¼", "com_ui_create": "作成", + "com_ui_create_assistant": "ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć‚’ä½œęˆ", "com_ui_create_link": "ćƒŖćƒ³ć‚Æć‚’ä½œęˆć™ć‚‹", "com_ui_create_memory": "ćƒ”ćƒ¢ćƒŖć‚’ä½œęˆć—ć¾ć™", + "com_ui_create_new_agent": "ę–°ć—ć„ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć‚’ä½œęˆ", "com_ui_create_prompt": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’ä½œęˆć™ć‚‹", + "com_ui_create_prompt_page": "ę–°ć—ć„ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆčØ­å®šćƒšćƒ¼ć‚ø", "com_ui_creating_image": "ē”»åƒć‚’ä½œęˆć—ć¦ć„ć¾ć™ć€‚ć—ć°ć‚‰ćę™‚é–“ćŒć‹ć‹ć‚‹å “åˆćŒć‚ć‚Šć¾ć™", "com_ui_current": "ē¾åœØ", "com_ui_currently_production": "ē¾åœØē”Ÿē”£äø­", "com_ui_custom": "ć‚«ć‚¹ć‚æćƒ ", "com_ui_custom_header_name": "ć‚«ć‚¹ć‚æćƒ ćƒ˜ćƒƒćƒ€ćƒ¼å", "com_ui_custom_prompt_mode": "ć‚«ć‚¹ć‚æćƒ ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćƒ¢ćƒ¼ćƒ‰", + "com_ui_dark_theme_enabled": "ćƒ€ćƒ¼ć‚Æćƒ†ćƒ¼ćƒžć‚’ęœ‰åŠ¹ć«ć™ć‚‹", "com_ui_dashboard": "ćƒ€ćƒƒć‚·ćƒ„ćƒœćƒ¼ćƒ‰", "com_ui_date": "ę—„ä»˜", "com_ui_date_april": "4月", @@ -809,6 +853,7 @@ "com_ui_date_previous_30_days": "過去30ę—„é–“", "com_ui_date_previous_7_days": "過去7ę—„é–“", "com_ui_date_september": "9月", + "com_ui_date_sort": "ę—„ä»˜é †", "com_ui_date_today": "今ꗄ", "com_ui_date_yesterday": "ę˜Øę—„", "com_ui_decline": "åŒę„ć—ć¾ć›ć‚“", @@ -816,15 +861,21 @@ "com_ui_delete": "削除", "com_ui_delete_action": "ć‚¢ć‚Æć‚·ćƒ§ćƒ³ć‚’å‰Šé™¤", "com_ui_delete_action_confirm": "ć“ć®ć‚¢ć‚Æć‚·ćƒ§ćƒ³ć‚’å‰Šé™¤ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹ļ¼Ÿ", + "com_ui_delete_agent": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć‚’å‰Šé™¤", "com_ui_delete_agent_confirm": "ć“ć®ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć‚’å‰Šé™¤ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹ļ¼Ÿ", + "com_ui_delete_assistant": "ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć‚’å‰Šé™¤", "com_ui_delete_assistant_confirm": "ć“ć®ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć‚’å‰Šé™¤ć—ć¾ć™ć‹ļ¼Ÿ ć“ć®ę“ä½œćÆå…ƒć«ęˆ»ć›ć¾ć›ć‚“ć€‚", "com_ui_delete_confirm": "ć“ć®ćƒćƒ£ćƒƒćƒˆćÆå‰Šé™¤ć•ć‚Œć¾ć™ć€‚", "com_ui_delete_confirm_prompt_version_var": "ć“ć‚ŒćÆć€éøęŠžć•ć‚ŒćŸćƒćƒ¼ć‚øćƒ§ćƒ³ć‚’ \"{{0}}.\" ć‹ć‚‰å‰Šé™¤ć—ć¾ć™ć€‚ä»–ć®ćƒćƒ¼ć‚øćƒ§ćƒ³ćŒå­˜åœØć—ćŖć„å “åˆć€ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćŒå‰Šé™¤ć•ć‚Œć¾ć™ć€‚", + "com_ui_delete_confirm_strong": "å‰Šé™¤ć—ć¾ć™ {{title}}", "com_ui_delete_conversation": "ćƒćƒ£ćƒƒćƒˆć‚’å‰Šé™¤ć—ć¾ć™ć‹ļ¼Ÿ", "com_ui_delete_memory": "ćƒ”ćƒ¢ćƒŖć®å‰Šé™¤", "com_ui_delete_not_allowed": "å‰Šé™¤ę“ä½œćÆčØ±åÆć•ć‚Œć¦ć„ć¾ć›ć‚“", + "com_ui_delete_preset": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’å‰Šé™¤ć—ć¾ć™ć‹?", "com_ui_delete_prompt": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’ę¶ˆć—ć¾ć™ć‹?", + "com_ui_delete_prompt_name": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć®å‰Šé™¤ - {{name}}", "com_ui_delete_shared_link": "å…±ęœ‰ćƒŖćƒ³ć‚Æć‚’å‰Šé™¤ć—ć¾ć™ć‹ļ¼Ÿ", + "com_ui_delete_shared_link_heading": "å…±ęœ‰ćƒŖćƒ³ć‚Æć‚’å‰Šé™¤", "com_ui_delete_success": "å‰Šé™¤ć«ęˆåŠŸ", "com_ui_delete_tool": "ćƒ„ćƒ¼ćƒ«ć‚’å‰Šé™¤", "com_ui_delete_tool_confirm": "ć“ć®ćƒ„ćƒ¼ćƒ«ć‚’å‰Šé™¤ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹ļ¼Ÿ", @@ -837,6 +888,7 @@ "com_ui_deselect_all": "ć™ć¹ć¦éøęŠžč§£é™¤", "com_ui_detailed": "詳瓰", "com_ui_disabling": "ē„”åŠ¹åŒ–...", + "com_ui_done": "å®Œäŗ†", "com_ui_download": "ćƒ€ć‚¦ćƒ³ćƒ­ćƒ¼ćƒ‰", "com_ui_download_artifact": "ć‚¢ćƒ¼ćƒ†ć‚£ćƒ•ć‚”ć‚Æćƒˆć‚’ćƒ€ć‚¦ćƒ³ćƒ­ćƒ¼ćƒ‰", "com_ui_download_backup": "ćƒćƒƒć‚Æć‚¢ćƒƒćƒ—ć‚³ćƒ¼ćƒ‰ć‚’ćƒ€ć‚¦ćƒ³ćƒ­ćƒ¼ćƒ‰ć™ć‚‹", @@ -847,13 +899,17 @@ "com_ui_dropdown_variables": "ćƒ‰ćƒ­ćƒƒćƒ—ćƒ€ć‚¦ćƒ³å¤‰ę•°:", "com_ui_dropdown_variables_info": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć®ć‚«ć‚¹ć‚æćƒ ćƒ‰ćƒ­ćƒƒćƒ—ćƒ€ć‚¦ćƒ³ćƒ”ćƒ‹ćƒ„ćƒ¼ć‚’ä½œęˆć—ć¾ć™: `{{variable_name:option1|option2|option3}}`", "com_ui_duplicate": "複製", + "com_ui_duplicate_agent": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć®é‡č¤‡", "com_ui_duplication_error": "ä¼šč©±ć®č¤‡č£½äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸ", "com_ui_duplication_processing": "ä¼šč©±ć‚’č¤‡č£½äø­...", "com_ui_duplication_success": "ä¼šč©±ć®č¤‡č£½ćŒå®Œäŗ†ć—ć¾ć—ćŸ", "com_ui_edit": "編集", "com_ui_edit_editing_image": "ē”»åƒē·Øé›†", "com_ui_edit_mcp_server": "MCPć‚µćƒ¼ćƒćƒ¼ć®ē·Øé›†", + "com_ui_edit_mcp_server_dialog_description": "äø€ę„ć®ć‚µćƒ¼ćƒćƒ¼č­˜åˆ„å­: {{serverName}}", "com_ui_edit_memory": "ćƒ”ćƒ¢ćƒŖē·Øé›†", + "com_ui_edit_preset_title": "ćƒ—ćƒŖć‚»ćƒƒćƒˆć®ē·Øé›† - {{title}}", + "com_ui_edit_prompt_page": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćƒšćƒ¼ć‚øć‚’ē·Øé›†", "com_ui_editable_message": "ē·Øé›†åÆčƒ½ćŖćƒ”ćƒƒć‚»ćƒ¼ć‚ø", "com_ui_editor_instructions": "ē”»åƒć‚’ćƒ‰ćƒ©ćƒƒć‚°ć—ć¦ä½ē½®ć‚’å¤‰ę›“ - ć‚ŗćƒ¼ćƒ ć‚¹ćƒ©ć‚¤ćƒ€ćƒ¼ć¾ćŸćÆćƒœć‚æćƒ³ć§ć‚µć‚¤ć‚ŗć‚’čŖæę•“", "com_ui_empty_category": "-", @@ -861,22 +917,29 @@ "com_ui_endpoint_menu": "LLMć‚Øćƒ³ćƒ‰ćƒć‚¤ćƒ³ćƒˆćƒ”ćƒ‹ćƒ„ćƒ¼", "com_ui_enter": "兄力", "com_ui_enter_api_key": "APIć‚­ćƒ¼ć‚’å…„åŠ›", + "com_ui_enter_description": "čŖ¬ę˜Žć‚’å…„åŠ›ļ¼ˆć‚Ŗćƒ—ć‚·ćƒ§ćƒ³ļ¼‰", "com_ui_enter_key": "ć‚­ćƒ¼ć‚’å…„åŠ›", + "com_ui_enter_name": "åå‰ć‚’å…„åŠ›", "com_ui_enter_openapi_schema": "OpenAPIć‚¹ć‚­ćƒ¼ćƒžć‚’å…„åŠ›ć—ć¦ćć ć•ć„", "com_ui_enter_value": "å€¤ć‚’å…„åŠ›", "com_ui_error": "ć‚Øćƒ©ćƒ¼", "com_ui_error_connection": "ć‚µćƒ¼ćƒćƒ¼ćøć®ęŽ„ē¶šäø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸć€‚ćƒšćƒ¼ć‚øć‚’ę›“ę–°ć—ć¦ćć ć•ć„ć€‚", + "com_ui_error_message_prefix": "ć‚Øćƒ©ćƒ¼ćƒ”ćƒƒć‚»ćƒ¼ć‚ø:", "com_ui_error_save_admin_settings": "ē®”ē†č€…čØ­å®šć®äæå­˜ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸć€‚", + "com_ui_error_try_following_prefix": "ę¬”ć®ć„ćšć‚Œć‹ć‚’č©¦ć—ć¦ćć ć•ć„", + "com_ui_error_unexpected": "äŗˆęœŸć—ćŖć„äŗ‹ę…‹ćŒē™ŗē”Ÿć—ć¾ć—ćŸ", "com_ui_error_updating_preferences": "ē’°å¢ƒčØ­å®šć®ę›“ę–°ć‚Øćƒ©ćƒ¼", "com_ui_everyone_permission_level": "å…Øå“”ć®čØ±åÆćƒ¬ćƒ™ćƒ«", "com_ui_examples": "例", + "com_ui_expand": "展開", "com_ui_expand_chat": "ćƒćƒ£ćƒƒćƒˆć‚’å±•é–‹", + "com_ui_expand_thoughts": "ę€č€ƒć‚’å±•é–‹", "com_ui_export_convo_modal": "ć‚Øć‚Æć‚¹ćƒćƒ¼ćƒˆ", "com_ui_feedback_more": "もっと...", "com_ui_feedback_more_information": "čæ½åŠ ćƒ•ć‚£ćƒ¼ćƒ‰ćƒćƒƒć‚Æć®ęä¾›", "com_ui_feedback_negative": "ę”¹å–„ćŒåæ…č¦", "com_ui_feedback_placeholder": "ćć®ä»–ć€ć”ę„č¦‹ćƒ»ć”ę„Ÿęƒ³ćŒć”ć–ć„ć¾ć—ćŸć‚‰ć€ć“ć”ć‚‰ć«ć”čØ˜å…„ćć ć•ć„ć€‚", - "com_ui_feedback_positive": "スキ!", + "com_ui_feedback_positive": "いいね!", "com_ui_feedback_tag_accurate_reliable": "ę­£ē¢ŗć§äæ”é ¼ć§ćć‚‹", "com_ui_feedback_tag_attention_to_detail": "ē“°éƒØćøć®ć“ć ć‚ć‚Š", "com_ui_feedback_tag_bad_style": "ć‚¹ć‚æć‚¤ćƒ«ć‚„å£čŖæćŒę‚Ŗć„", @@ -895,6 +958,7 @@ "com_ui_file_token_limit": "ćƒ•ć‚”ć‚¤ćƒ«ćƒ»ćƒˆćƒ¼ć‚Æćƒ³ć®åˆ¶é™", "com_ui_file_token_limit_desc": "ćƒ•ć‚”ć‚¤ćƒ«å‡¦ē†ć®ćƒˆćƒ¼ć‚Æćƒ³äøŠé™ć‚’čØ­å®šć—ć€ć‚³ć‚¹ćƒˆćØćƒŖć‚½ćƒ¼ć‚¹ć®ä½æē”Øé‡ć‚’ē®”ē†ć™ć‚‹ć€‚", "com_ui_files": "ćƒ•ć‚”ć‚¤ćƒ«", + "com_ui_filter_mcp_servers": "åå‰ć§MCPć‚µćƒ¼ćƒćƒ¼ć‚’ćƒ•ć‚£ćƒ«ć‚æćƒŖćƒ³ć‚°", "com_ui_filter_prompts": "ćƒ•ć‚£ćƒ«ć‚æćƒ¼ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆ", "com_ui_filter_prompts_name": "åå‰ć§ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’ćƒ•ć‚£ćƒ«ć‚æ", "com_ui_final_touch": "ęœ€å¾Œć®ä»•äøŠć’", @@ -918,6 +982,7 @@ "com_ui_fork_info_visible": "ć“ć®čØ­å®šćÆć€ć‚æćƒ¼ć‚²ćƒƒćƒˆćƒ”ćƒƒć‚»ćƒ¼ć‚øćøć®ē›“ęŽ„ć®ēµŒč·Æć®ćæć‚’č”Øē¤ŗć—ć€åˆ†å²ćÆč”Øē¤ŗć—ć¾ć›ć‚“ć€‚ć¤ć¾ć‚Šć€č”Øē¤ŗćƒ”ćƒƒć‚»ćƒ¼ć‚øć®ćæć‚’ęŠ½å‡ŗć—ć¦č”Øē¤ŗć™ć‚‹ćØć„ć†ć“ćØć§ć™ć€‚", "com_ui_fork_more_details_about": "{{0}} åˆ†å²ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³ć«é–¢ć™ć‚‹čæ½åŠ ęƒ…å ±ćØč©³ē“°ć‚’č”Øē¤ŗć—ć¾ć™", "com_ui_fork_more_info_options": "ć™ć¹ć¦ć®åˆ†å²ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³ćØćć®å‹•ä½œć®č©³ē“°čŖ¬ę˜Žć‚’č¦‹ć‚‹", + "com_ui_fork_open_menu": "åˆ†å²ć‚Ŗćƒ—ć‚·ćƒ§ćƒ³ć‚’é–‹ć", "com_ui_fork_processing": "ä¼šč©±ć‚’åˆ†å²ć—ć¦ć„ć¾ć™...", "com_ui_fork_remember": "ä»„å‰ć®ä¼šč©±å†…å®¹ć‚’čØ˜ę†¶ć™ć‚‹", "com_ui_fork_remember_checked": "éøęŠžć—ćŸå†…å®¹ćÆć€ę¬”å›žć®åˆ©ē”Øę™‚ć«ć‚‚čØ˜ę†¶ć•ć‚Œć¾ć™ć€‚čØ­å®šć‹ć‚‰å¤‰ę›“ć§ćć¾ć™ć€‚", @@ -938,6 +1003,8 @@ "com_ui_group": "ć‚°ćƒ«ćƒ¼ćƒ—", "com_ui_handoff_instructions": "ćƒćƒ³ćƒ‰ć‚Ŗćƒ•ć®ęŒ‡ē¤ŗ", "com_ui_happy_birthday": "åˆć‚ć¦ć®čŖ•ē”Ÿę—„ć§ć™ļ¼", + "com_ui_header_format": "ćƒ˜ćƒƒćƒ€ćƒ¼å½¢å¼", + "com_ui_hide_code": "ć‚³ćƒ¼ćƒ‰ć‚’éš ć™", "com_ui_hide_image_details": "ē”»åƒć®č©³ē“°ć‚’éš ć™", "com_ui_hide_password": "ćƒ‘ć‚¹ćƒÆćƒ¼ćƒ‰ć‚’éš ć™", "com_ui_hide_qr": "QRć‚³ćƒ¼ćƒ‰ć‚’éžč”Øē¤ŗć«ć™ć‚‹", @@ -955,6 +1022,7 @@ "com_ui_import_conversation_info": "JSONćƒ•ć‚”ć‚¤ćƒ«ć‹ć‚‰ä¼šč©±ć‚’ć‚¤ćƒ³ćƒćƒ¼ćƒˆć™ć‚‹", "com_ui_import_conversation_success": "ä¼šč©±ć®ć‚¤ćƒ³ćƒćƒ¼ćƒˆć«ęˆåŠŸć—ć¾ć—ćŸ", "com_ui_import_conversation_upload_error": "ćƒ•ć‚”ć‚¤ćƒ«ć®ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰ć«å¤±ę•—ć—ć¾ć—ćŸć€‚ć‚‚ć†äø€åŗ¦ćŠč©¦ć—ćć ć•ć„ć€‚", + "com_ui_importing": "ć‚¤ćƒ³ćƒćƒ¼ćƒˆäø­", "com_ui_include_shadcnui": "shadcn/uić‚³ćƒ³ćƒćƒ¼ćƒćƒ³ćƒˆć®ęŒ‡ē¤ŗć‚’å«ć‚ć‚‹", "com_ui_initializing": "åˆęœŸåŒ–äø­...", "com_ui_input": "兄力", @@ -965,9 +1033,13 @@ "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_production_version": "ęœ€ę–°ć®č£½å“ćƒćƒ¼ć‚øćƒ§ćƒ³", "com_ui_latest_version": "ęœ€ę–°ćƒćƒ¼ć‚øćƒ§ćƒ³", + "com_ui_leave_blank_to_keep": "ę—¢å­˜ć‚’ē¶­ęŒć™ć‚‹å “åˆćÆē©ŗē™½ć®ć¾ć¾ć«ć—ć¦äø‹ć•ć„", "com_ui_librechat_code_api_key": "LibreChat ć‚³ćƒ¼ćƒ‰ć‚¤ćƒ³ć‚æćƒ¼ćƒ—ćƒŖć‚æćƒ¼ APIć‚­ćƒ¼ć‚’å–å¾—", "com_ui_librechat_code_api_subtitle": "ć‚»ć‚­ćƒ„ć‚¢ć€‚å¤ščØ€čŖžåÆ¾åæœć€‚ćƒ•ć‚”ć‚¤ćƒ«å…„å‡ŗåŠ›ć€‚", "com_ui_librechat_code_api_title": "AIć‚³ćƒ¼ćƒ‰ć‚’å®Ÿč”Œ", + "com_ui_light_theme_enabled": "ćƒ©ć‚¤ćƒˆćƒ†ćƒ¼ćƒžćŒęœ‰åŠ¹", + "com_ui_link_copied": "ćƒŖćƒ³ć‚Æć‚’ć‚³ćƒ”ćƒ¼ć—ć¾ć—ćŸ", + "com_ui_link_refreshed": "ćƒŖćƒ³ć‚Æć‚’ę›“ę–°ć—ć¾ć—ćŸ", "com_ui_loading": "読み込み中...", "com_ui_locked": "ćƒ­ćƒƒć‚Æ", "com_ui_logo": "{{0}}ć®ćƒ­ć‚“", @@ -975,18 +1047,41 @@ "com_ui_manage": "箔理", "com_ui_marketplace": "ćƒžćƒ¼ć‚±ćƒƒćƒˆćƒ—ćƒ¬ć‚¤ć‚¹", "com_ui_marketplace_allow_use": "ćƒžćƒ¼ć‚±ćƒƒćƒˆćƒ—ćƒ¬ć‚¤ć‚¹ć®åˆ©ē”Øć‚’čØ±åÆć™ć‚‹", + "com_ui_max_favorites_reached": "ćƒ”ćƒ³ē•™ć‚ć—ćŸć‚¢ć‚¤ćƒ†ćƒ ć®ęœ€å¤§ę•°ć«é”ć—ć¾ć—ćŸļ¼ˆ{{0}}ļ¼‰ć€‚ć‚¢ć‚¤ćƒ†ćƒ ć‚’čæ½åŠ ć™ć‚‹ć«ćÆć€ćƒ”ćƒ³ē•™ć‚ć‚’č§£é™¤ć—ć¾ć™ć€‚", "com_ui_max_file_size": "PNG态JPGまたはJPEGļ¼ˆęœ€å¤§ {{0}})", "com_ui_max_tags": "ęœ€ę–°ć®å€¤ć‚’ä½æē”Øć—ćŸå “åˆć€čØ±åÆć•ć‚Œć‚‹ęœ€å¤§ę•°ćÆ {{0}} 恧恙怂", "com_ui_mcp_authenticated_success": "MCPć‚µćƒ¼ćƒćƒ¼{{0}}čŖčØ¼ęˆåŠŸ", "com_ui_mcp_configure_server": "設定 {{0}}", "com_ui_mcp_configure_server_description": "ć‚«ć‚¹ć‚æćƒ å¤‰ę•°ć‚’čØ­å®šć™ć‚‹ {{0}}", + "com_ui_mcp_dialog_title": "å¤‰ę•°ć‚’čØ­å®šć™ć‚‹ {{serverName}}. ć‚µćƒ¼ćƒćƒ¼ć‚¹ćƒ†ćƒ¼ć‚æć‚¹: {{status}}", + "com_ui_mcp_domain_not_allowed": "MCPć‚µćƒ¼ćƒćƒ¼ćƒ‰ćƒ”ć‚¤ćƒ³ćŒčØ±åÆćƒ‰ćƒ”ć‚¤ćƒ³ćƒŖć‚¹ćƒˆć«ć‚ć‚Šć¾ć›ć‚“ć€‚ē®”ē†č€…ć«é€£ēµ”ć—ć¦ćć ć•ć„ć€‚", "com_ui_mcp_enter_var": "{{0}}ć®å€¤ć‚’å…„åŠ›ć™ć‚‹ć€‚", "com_ui_mcp_init_failed": "MCPć‚µćƒ¼ćƒćƒ¼ć®åˆęœŸåŒ–ć«å¤±ę•—ć—ć¾ć—ćŸ", "com_ui_mcp_initialize": "åˆęœŸåŒ–", "com_ui_mcp_initialized_success": "MCPć‚µćƒ¼ćƒćƒ¼{{0}}åˆęœŸåŒ–ć«ęˆåŠŸ", + "com_ui_mcp_invalid_url": "ęœ‰åŠ¹ćŖ URL ć‚’å…„åŠ›ć—ć¦ćć ć•ć„", "com_ui_mcp_oauth_cancelled": "OAuthćƒ­ć‚°ć‚¤ćƒ³ćŒć‚­ćƒ£ćƒ³ć‚»ćƒ«ć•ć‚ŒćŸ {{0}}", "com_ui_mcp_oauth_timeout": "OAuthćƒ­ć‚°ć‚¤ćƒ³ćŒć‚æć‚¤ćƒ ć‚¢ć‚¦ćƒˆć—ć¾ć—ćŸć€‚ {{0}}", + "com_ui_mcp_server": "MCP ć‚µćƒ¼ćƒćƒ¼", + "com_ui_mcp_server_connection_failed": "ęŒ‡å®šć•ć‚ŒćŸMCPć‚µćƒ¼ćƒćƒ¼ćøć®ęŽ„ē¶šć«å¤±ę•—ć—ć¾ć—ćŸć€‚URLć€ć‚µćƒ¼ćƒćƒ¼ć®ēØ®é”žć€ćŠć‚ˆć³čŖčØ¼čØ­å®šćŒę­£ć—ć„ć“ćØć‚’ē¢ŗčŖć—ć¦ć‹ć‚‰ć€ć‚‚ć†äø€åŗ¦ćŠč©¦ć—ćć ć•ć„ć€‚ć¾ćŸć€URLć«ć‚¢ć‚Æć‚»ć‚¹ć§ćć‚‹ć“ćØć‚’ē¢ŗčŖć—ć¦ćć ć•ć„ć€‚", + "com_ui_mcp_server_created": "MCP ć‚µćƒ¼ćƒćƒ¼ćŒę­£åøøć«ä½œęˆć•ć‚Œć¾ć—ćŸ", + "com_ui_mcp_server_delete_confirm": "恓恮 MCP ć‚µćƒ¼ćƒćƒ¼ć‚’å‰Šé™¤ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹?", + "com_ui_mcp_server_deleted": "MCP ć‚µćƒ¼ćƒćƒ¼ćŒę­£åøøć«å‰Šé™¤ć•ć‚Œć¾ć—ćŸ", + "com_ui_mcp_server_role_editor": "MCPć‚µćƒ¼ćƒćƒ¼ć‚Øćƒ‡ć‚£ć‚æćƒ¼", + "com_ui_mcp_server_role_editor_desc": "MCP ć‚µćƒ¼ćƒćƒ¼ć‚’č”Øē¤ŗć€ä½æē”Øć€ē·Øé›†ć§ćć¾ć™", + "com_ui_mcp_server_role_owner": "MCP ć‚µćƒ¼ćƒćƒ¼ę‰€ęœ‰č€…", + "com_ui_mcp_server_role_owner_desc": "MCP ć‚µćƒ¼ćƒćƒ¼ć‚’å®Œå…Øć«åˆ¶å¾”", + "com_ui_mcp_server_role_viewer": "MCP ć‚µćƒ¼ćƒćƒ¼ ćƒ“ćƒ„ćƒ¼ć‚¢ćƒ¼", + "com_ui_mcp_server_role_viewer_desc": "MCPć‚µćƒ¼ćƒćƒ¼ć®č”Øē¤ŗćØä½æē”ØćŒåÆčƒ½", + "com_ui_mcp_server_updated": "MCPć‚µćƒ¼ćƒćƒ¼ćŒę­£åøøć«ę›“ę–°ć•ć‚Œć¾ć—ćŸ", + "com_ui_mcp_server_url_placeholder": "https://mcp.example.com", "com_ui_mcp_servers": "MCP ć‚µćƒ¼ćƒćƒ¼", + "com_ui_mcp_servers_allow_create": "ćƒ¦ćƒ¼ć‚¶ć«MCPć‚µćƒ¼ćƒćƒ¼ć‚’ä½œęˆčØ±åÆć™ć‚‹", + "com_ui_mcp_servers_allow_share": "ćƒ¦ćƒ¼ć‚¶ćƒ¼ć«MCPć‚µćƒ¼ćƒćƒ¼ć‚’å…±ęœ‰čØ±åÆć™ć‚‹", + "com_ui_mcp_servers_allow_use": "ćƒ¦ćƒ¼ć‚¶ćƒ¼ć« MCP ć‚µćƒ¼ćƒćƒ¼ć®ä½æē”Øć‚’čØ±åÆć™ć‚‹", + "com_ui_mcp_title_invalid": "ć‚æć‚¤ćƒˆćƒ«ć«ä½æē”Øć§ćć‚‹ć®ćÆć€ć‚¢ćƒ«ćƒ•ć‚”ćƒ™ćƒƒćƒˆć€ę•°å­—ć€ć‚¹ćƒšćƒ¼ć‚¹ć®ćæć§ć™ć€‚", + "com_ui_mcp_type_sse": "SSE", + "com_ui_mcp_type_streamable_http": "ć‚¹ćƒˆćƒŖćƒ¼ćƒŸćƒ³ć‚°åÆčƒ½ćŖHTTPS", "com_ui_mcp_update_var": "{{0}}悒ꛓꖰ", "com_ui_mcp_url": "MCPć‚µćƒ¼ćƒćƒ¼URL", "com_ui_medium": "äø­", @@ -1004,13 +1099,18 @@ "com_ui_memory_deleted_items": "å‰Šé™¤ć•ć‚ŒćŸćƒ”ćƒ¢ćƒŖ", "com_ui_memory_error": "ćƒ”ćƒ¢ćƒŖć‚Øćƒ©ćƒ¼", "com_ui_memory_key_exists": "ć“ć®ć‚­ćƒ¼ć‚’ęŒć¤ćƒ”ćƒ¢ćƒŖćÆć™ć§ć«å­˜åœØć—ć¾ć™ć€‚åˆ„ć®ć‚­ćƒ¼ć‚’ä½æē”Øć—ć¦ćć ć•ć„ć€‚", + "com_ui_memory_key_hint": "å°ę–‡å­—ćØć‚¢ćƒ³ćƒ€ćƒ¼ć‚¹ć‚³ć‚¢ć®ćæć‚’ä½æē”Øć—ć¦ćć ć•ć„", "com_ui_memory_key_validation": "ćƒ”ćƒ¢ćƒŖćƒ¼ćƒ»ć‚­ćƒ¼ć«ćÆå°ę–‡å­—ćØć‚¢ćƒ³ćƒ€ćƒ¼ć‚¹ć‚³ć‚¢ć®ćæć‚’ä½æē”Øć™ć‚‹ć€‚", "com_ui_memory_storage_full": "ćƒ”ćƒ¢ćƒŖć‚¹ćƒˆćƒ¬ćƒ¼ć‚øćŒć„ć£ć±ć„ć§ć™", "com_ui_memory_updated": "äæå­˜ć•ć‚ŒćŸćƒ”ćƒ¢ćƒŖć‚’ę›“ę–°ć—ć¾ć—ćŸ", "com_ui_memory_updated_items": "ę›“ę–°ć•ć‚ŒćŸćƒ”ćƒ¢ćƒŖ", "com_ui_memory_would_exceed": "äæå­˜ć§ćć¾ć›ć‚“ - åˆ¶é™ć‚’č¶…ćˆć¦ć„ć¾ć™ {{tokens}} ćƒˆćƒ¼ć‚Æćƒ³ć€‚ę—¢å­˜ć®ćƒ”ćƒ¢ćƒŖć‚’å‰Šé™¤ć—ć¦ć‚¹ćƒšćƒ¼ć‚¹ć‚’ē¢ŗäæć—ć¾ć™ć€‚", "com_ui_mention": "ć‚Øćƒ³ćƒ‰ćƒć‚¤ćƒ³ćƒˆć€ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆć€ć¾ćŸćÆćƒ—ćƒŖć‚»ćƒƒćƒˆć‚’ē“ ę—©ćåˆ‡ć‚Šę›æćˆć‚‹ć«ćÆć€ćć‚Œć‚‰ć‚’čØ€åŠć—ć¦ćć ć•ć„ć€‚", + "com_ui_mermaid": "ćƒžćƒ¼ćƒ”ć‚¤ćƒ‰", + "com_ui_mermaid_failed": "å›³ć®ćƒ¬ćƒ³ćƒ€ćƒŖćƒ³ć‚°ć«å¤±ę•—ć—ć¾ć—ćŸ :", + "com_ui_mermaid_source": "ć‚½ćƒ¼ć‚¹ć‚³ćƒ¼ćƒ‰:", "com_ui_message_input": "ćƒ”ćƒƒć‚»ćƒ¼ć‚øå…„åŠ›", + "com_ui_microphone_unavailable": "ćƒžć‚¤ć‚Æć‚’ä½æē”Øć§ćć¾ć›ć‚“", "com_ui_min_tags": "ć“ć‚Œä»„äøŠć®å€¤ć‚’å‰Šé™¤ć§ćć¾ć›ć‚“ć€‚å°‘ćŖććØć‚‚ {{0}} ćŒåæ…č¦ć§ć™ć€‚", "com_ui_minimal": "ęœ€å°é™", "com_ui_misc": "ćć®ä»–", @@ -1019,18 +1119,27 @@ "com_ui_more_info": "詳瓰", "com_ui_my_prompts": "惞悤 ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆ", "com_ui_name": "名前", + "com_ui_name_sort": "名前順", "com_ui_new": "New", "com_ui_new_chat": "ę–°č¦ćƒćƒ£ćƒƒćƒˆ", "com_ui_new_conversation_title": "ę–°ć—ć„ä¼šč©±ć‚æć‚¤ćƒˆćƒ«", "com_ui_next": "ꬔ", "com_ui_no": "恄恄恈", + "com_ui_no_auth": "čŖčØ¼ćŖć—", "com_ui_no_bookmarks": "ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚ÆćŒć¾ć ćŖć„ć‚ˆć†ć§ć™ć€‚ćƒćƒ£ćƒƒćƒˆć‚’ć‚ÆćƒŖćƒƒć‚Æć—ć¦ę–°ć—ć„ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚Æć‚’čæ½åŠ ć—ć¦ćć ć•ć„", + "com_ui_no_bookmarks_match": "ę¤œē“¢ć«äø€č‡“ć™ć‚‹ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚ÆćÆć‚ć‚Šć¾ć›ć‚“", + "com_ui_no_bookmarks_title": "ćƒ–ćƒƒć‚Æćƒžćƒ¼ć‚ÆćÆć‚ć‚Šć¾ć›ć‚“", "com_ui_no_categories": "ć‚«ćƒ†ć‚“ćƒŖćƒ¼ćŖć—", "com_ui_no_category": "ć‚«ćƒ†ć‚“ćƒŖćŖć—", "com_ui_no_changes": "変曓なし", "com_ui_no_individual_access": "å€‹ć€…ć®ćƒ¦ćƒ¼ć‚¶ćƒ¼ć‚„ć‚°ćƒ«ćƒ¼ćƒ—ćŒć“ć®ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć«ć‚¢ć‚Æć‚»ć‚¹ć™ć‚‹ć“ćØćÆć§ćć¾ć›ć‚“ć€‚", - "com_ui_no_memories": "čØ˜ę†¶ćÆćŖć„ć€‚ę‰‹å‹•ć§ä½œęˆć™ć‚‹ć‹ć€AIć«ä½•ć‹ć‚’čØ˜ę†¶ć™ć‚‹ć‚ˆć†äæƒć™", + "com_ui_no_mcp_servers": "MCPć‚µćƒ¼ćƒćƒ¼ćÆć¾ć ć‚ć‚Šć¾ć›ć‚“", + "com_ui_no_mcp_servers_match": "ćƒ•ć‚£ćƒ«ć‚æćƒ¼ć«äø€č‡“ć™ć‚‹MCPć‚µćƒ¼ćƒćƒ¼ćÆć‚ć‚Šć¾ć›ć‚“", + "com_ui_no_memories": "ćƒ”ćƒ¢ćƒŖē™»éŒ²ćÆć‚ć‚Šć¾ć›ć‚“ć€‚ę–°č¦ć«ä½œęˆć™ć‚‹ć‹ć€AIć«č¦šćˆć‚‹ć‚ˆć†ęŒ‡ē¤ŗć—ć¦ćć ć•ć„ć€‚", + "com_ui_no_memories_match": "ę¤œē“¢ć«äø€č‡“ć™ć‚‹ćƒ”ćƒ¢ćƒŖćÆć‚ć‚Šć¾ć›ć‚“", + "com_ui_no_memories_title": "ć¾ć ćƒ”ćƒ¢ćƒŖćŒć‚ć‚Šć¾ć›ć‚“", "com_ui_no_personalization_available": "ē¾åœØć€ćƒ‘ćƒ¼ć‚½ćƒŠćƒ©ć‚¤ć‚ŗć‚Ŗćƒ—ć‚·ćƒ§ćƒ³ćÆć‚ć‚Šć¾ć›ć‚“", + "com_ui_no_prompts_title": "ć¾ć ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćÆć‚ć‚Šć¾ć›ć‚“", "com_ui_no_read_access": "ćƒ”ćƒ¢ćƒŖć‚’č¦‹ć‚‹ęØ©é™ćŒć‚ć‚Šć¾ć›ć‚“", "com_ui_no_results_found": "ēµęžœćÆč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“ć§ć—ćŸ", "com_ui_no_terms_content": "č”Øē¤ŗć™ć‚‹åˆ©ē”Øč¦ē“„ć®å†…å®¹ćÆć‚ć‚Šć¾ć›ć‚“", @@ -1051,7 +1160,11 @@ "com_ui_off": "ć‚Ŗćƒ•", "com_ui_offline": "ć‚Ŗćƒ•ćƒ©ć‚¤ćƒ³", "com_ui_on": "ć‚Ŗćƒ³", + "com_ui_open_source_chat_new_tab": "ę–°ć—ć„ć‚æćƒ–ć§ćƒćƒ£ćƒƒćƒˆć‚’é–‹ć", + "com_ui_open_source_chat_new_tab_title": "ę–°ć—ć„ć‚æćƒ–ć§ćƒćƒ£ćƒƒćƒˆć‚’é–‹ć- {{title}}", + "com_ui_open_var": "開恏 {{0}}", "com_ui_openai": "OpenAI", + "com_ui_opens_new_tab": "(ę–°ć—ć„ć‚æćƒ–ć§é–‹ć)", "com_ui_optional": "ļ¼ˆä»»ę„ļ¼‰", "com_ui_page": "ćƒšćƒ¼ć‚ø", "com_ui_people": "人々", @@ -1062,12 +1175,15 @@ "com_ui_permissions_failed_load": "ć‚¢ć‚Æć‚»ć‚¹čØ±åÆć®čŖ­ćæč¾¼ćæć«å¤±ę•—ć—ć¾ć—ćŸć€‚å†č©¦č”Œć—ć¦ćć ć•ć„ć€‚", "com_ui_permissions_failed_update": "ęØ©é™ć®ę›“ę–°ć«å¤±ę•—ć—ć¾ć—ćŸć€‚å†č©¦č”Œć—ć¦ćć ć•ć„ć€‚", "com_ui_permissions_updated_success": "ćƒ‘ćƒ¼ćƒŸćƒƒć‚·ćƒ§ćƒ³ć®ę›“ę–°ć«ęˆåŠŸ", + "com_ui_pin": "ćƒ”ćƒ³ē•™ć‚", "com_ui_preferences_updated": "ē’°å¢ƒčØ­å®šćŒę­£åøøć«ę›“ę–°ć•ć‚Œć¾ć—ćŸ", "com_ui_prev": "前", "com_ui_preview": "ćƒ—ćƒ¬ćƒ“ćƒ„ćƒ¼", "com_ui_privacy_policy": "ćƒ—ćƒ©ć‚¤ćƒć‚·ćƒ¼ćƒćƒŖć‚·ćƒ¼", "com_ui_privacy_policy_url": "ćƒ—ćƒ©ć‚¤ćƒć‚·ćƒ¼ćƒćƒŖć‚·ćƒ¼URL", "com_ui_prompt": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆ", + "com_ui_prompt_group_button": "{{name}} ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć€ {{category}} ć‚«ćƒ†ć‚“ćƒŖ", + "com_ui_prompt_group_button_no_category": "{{name}} ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆ", "com_ui_prompt_groups": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚°ćƒ«ćƒ¼ćƒ—ćƒŖć‚¹ćƒˆ", "com_ui_prompt_input": "å…„åŠ›ć‚’äæƒć™", "com_ui_prompt_input_field": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆćƒ»ćƒ†ć‚­ć‚¹ćƒˆå…„åŠ›ćƒ•ć‚£ćƒ¼ćƒ«ćƒ‰", @@ -1084,6 +1200,7 @@ "com_ui_provider": "惗惭惐悤惀", "com_ui_quality": "品質", "com_ui_read_aloud": "čŖ­ćæäøŠć’ć‚‹", + "com_ui_redirect_uri": "ćƒŖćƒ€ć‚¤ćƒ¬ć‚ÆćƒˆURI", "com_ui_redirecting_to_provider": "{{0}}ć«ćƒŖćƒ€ć‚¤ćƒ¬ć‚Æćƒˆć€ ćŠå¾…ć”ćć ć•ć„...", "com_ui_reference_saved_memories": "äæå­˜ć•ć‚ŒćŸćƒ”ćƒ¢ćƒŖć‚’å‚ē…§", "com_ui_reference_saved_memories_description": "ć‚¢ć‚·ć‚¹ć‚æćƒ³ćƒˆćŒåæœē­”ć™ć‚‹éš›ć«ć€äæå­˜ć—ćŸćƒ”ćƒ¢ćƒŖć‚’å‚ē…§ć—ć€ä½æē”Øć§ćć‚‹ć‚ˆć†ć«ć™ć‚‹ć€‚", @@ -1101,6 +1218,7 @@ "com_ui_rename_conversation": "ä¼šč©±ć®åå‰ć‚’å¤‰ę›“ć™ć‚‹", "com_ui_rename_failed": "ä¼šč©±ć®åå‰ć‚’å¤‰ę›“ć§ćć¾ć›ć‚“ć§ć—ćŸ", "com_ui_rename_prompt": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć®åå‰ć‚’å¤‰ę›“ć—ć¾ć™", + "com_ui_rename_prompt_name": "ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć®åå‰å¤‰ę›“ - {{name}}", "com_ui_requires_auth": "čŖčØ¼ćŒåæ…č¦ć§ć™", "com_ui_reset": "ćƒŖć‚»ćƒƒćƒˆ", "com_ui_reset_adjustments": "čŖæę•“ć‚’ćƒŖć‚»ćƒƒćƒˆć™ć‚‹", @@ -1109,6 +1227,9 @@ "com_ui_resource": "ćƒŖć‚½ćƒ¼ć‚¹", "com_ui_response": "åæœē­”", "com_ui_result": "ēµęžœ", + "com_ui_result_found": "{{count}} ä»¶ć®ēµęžœćŒč¦‹ć¤ć‹ć‚Šć¾ć—ćŸ", + "com_ui_results_found": "{{count}} ä»¶ć®ēµęžœćŒč¦‹ć¤ć‹ć‚Šć¾ć—ćŸ", + "com_ui_retry": "ćƒŖćƒˆćƒ©ć‚¤", "com_ui_revoke": "ē„”åŠ¹ć«ć™ć‚‹", "com_ui_revoke_info": "ćƒ¦ćƒ¼ć‚¶ćøē™ŗč”Œć—ćŸčŖčØ¼ęƒ…å ±ć‚’ć™ć¹ć¦ē„”åŠ¹ć«ć™ć‚‹ć€‚", "com_ui_revoke_key_confirm": "ć“ć®čŖčØ¼ęƒ…å ±ć‚’ē„”åŠ¹ć«ć—ć¦ć‚‚ć‚ˆć‚ć—ć„ć§ć™ć‹ļ¼Ÿ", @@ -1152,6 +1273,7 @@ "com_ui_seconds": "ē§’", "com_ui_secret_key": "ē§˜åÆ†éµ", "com_ui_select": "éøęŠž", + "com_ui_select_agent": "ć‚Øćƒ¼ć‚øć‚§ćƒ³ćƒˆć‚’éøęŠž", "com_ui_select_all": "ć™ć¹ć¦éøęŠž", "com_ui_select_file": "ćƒ•ć‚”ć‚¤ćƒ«ć‚’éøęŠž", "com_ui_select_model": "ćƒ¢ćƒ‡ćƒ«éøęŠž", @@ -1160,6 +1282,7 @@ "com_ui_select_provider": "ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć‚’éøęŠžć—ć¦ćć ć•ć„", "com_ui_select_provider_first": "ęœ€åˆć«ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć‚’éøęŠžć—ć¦ćć ć•ć„", "com_ui_select_region": "åœ°åŸŸć‚’éøęŠž", + "com_ui_select_row": "č”Œć‚’éøęŠž", "com_ui_select_search_model": "åå‰ć§ćƒ¢ćƒ‡ćƒ«ć‚’ę¤œē“¢", "com_ui_select_search_provider": "ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼åć§ę¤œē“¢", "com_ui_select_search_region": "åœ°åŸŸåć§ę¤œē“¢", @@ -1169,7 +1292,7 @@ "com_ui_share_delete_error": "å…±ęœ‰ćƒŖćƒ³ć‚Æć®å‰Šé™¤äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸć€‚", "com_ui_share_error": "ćƒćƒ£ćƒƒćƒˆć®å…±ęœ‰ćƒŖćƒ³ć‚Æć®å…±ęœ‰äø­ć«ć‚Øćƒ©ćƒ¼ćŒē™ŗē”Ÿć—ć¾ć—ćŸ", "com_ui_share_everyone": "ćæć‚“ćŖćØå…±ęœ‰ć™ć‚‹", - "com_ui_share_everyone_description_var": "恓悌 {{resource}} čŖ°ć§ć‚‚ć”åˆ©ē”Øć„ćŸć ć‘ć¾ć™ć€‚ {{resource}} ęœ¬å½“ćÆćæć‚“ćŖć§å…±ęœ‰ć™ć‚‹ć¹ćć‚‚ć®ć§ć™ć€‚ćƒ‡ćƒ¼ć‚æć®å–ć‚Šę‰±ć„ć«ćÆć”ę³Øę„ćć ć•ć„ć€‚", + "com_ui_share_everyone_description_var": "恓恮 {{resource}} ćÆå…Øå“”ćŒåˆ©ē”Øć§ćć‚‹ć‚ˆć†ć«ćŖć‚Šć¾ć™ć€‚ć“ć® {{resource}} ćŒęœ¬å½“ć«å…Øå“”ćØå…±ęœ‰ć™ć‚‹ć“ćØć‚’ę„å›³ć—ćŸć‚‚ć®ć‹ć€åæ…ćšē¢ŗčŖć—ć¦ćć ć•ć„ć€‚ćƒ‡ćƒ¼ć‚æć®å–ć‚Šę‰±ć„ć«ćÆę³Øę„ć—ć¦ćć ć•ć„ć€‚", "com_ui_share_link_to_chat": "ćƒćƒ£ćƒƒćƒˆćøć®å…±ęœ‰ćƒŖćƒ³ć‚Æ", "com_ui_share_qr_code_description": "ć“ć®ä¼šč©±ćƒŖćƒ³ć‚Æć‚’å…±ęœ‰ć™ć‚‹ćŸć‚ć®QRć‚³ćƒ¼ćƒ‰", "com_ui_share_update_message": "ć‚ćŖćŸć®åå‰ć€ć‚«ć‚¹ć‚æćƒ ęŒ‡ē¤ŗć€å…±ęœ‰ćƒŖćƒ³ć‚Æć‚’ä½œęˆć—ćŸå¾Œć®ćƒ”ćƒƒć‚»ćƒ¼ć‚øćÆć€å…±ęœ‰ć•ć‚Œć¾ć›ć‚“ć€‚", @@ -1179,22 +1302,30 @@ "com_ui_shared_prompts": "å…±ęœ‰ć•ć‚ŒćŸćƒ—ćƒ­ćƒ³ćƒ—ćƒˆ", "com_ui_shop": "買い物", "com_ui_show_all": "すべて蔨示", + "com_ui_show_code": "ć‚³ćƒ¼ćƒ‰č”Øē¤ŗ", "com_ui_show_image_details": "ē”»åƒć®č©³ē“°ć‚’č”Øē¤ŗ", "com_ui_show_password": "ćƒ‘ć‚¹ćƒÆćƒ¼ćƒ‰ć‚’č”Øē¤ŗć™ć‚‹", "com_ui_show_qr": "QR ć‚³ćƒ¼ćƒ‰ć‚’č”Øē¤ŗ", "com_ui_sign_in_to_domain": "{{0}}ć«ć‚µć‚¤ćƒ³ć‚¤ćƒ³ć™ć‚‹", "com_ui_simple": "ć‚·ćƒ³ćƒ—ćƒ«", "com_ui_size": "サイズ", + "com_ui_size_sort": "サイズ順", "com_ui_special_var_current_date": "ē¾åœØć®ę—„ä»˜", "com_ui_special_var_current_datetime": "ē¾åœØć®ę—„ę™‚", "com_ui_special_var_current_user": "ē¾åœØć®ćƒ¦ćƒ¼ć‚¶ćƒ¼", "com_ui_special_var_iso_datetime": "UTC ISO ꗄꙂ", "com_ui_special_variables": "ē‰¹ę®Šå¤‰ę•°:", "com_ui_special_variables_more_info": "ćƒ‰ćƒ­ćƒƒćƒ—ćƒ€ć‚¦ćƒ³ć‹ć‚‰ē‰¹åˆ„ćŖå¤‰ę•°ć‚’éøęŠžć§ćć¾ć™: `{{current_date}}` (ä»Šę—„ć®ę—„ä»˜ćØę›œę—„)态`{{current_datetime}}` (ē¾åœ°ć®ę—„ä»˜ćØę™‚åˆ»)态`{{utc_iso_datetime}}` (UTC ISO ꗄꙂ)ć€ćŠć‚ˆć³ `{{current_user}}` (ć‚¢ć‚«ć‚¦ćƒ³ćƒˆå)怂", + "com_ui_speech_not_supported": "ćŠä½æć„ć®ćƒ–ćƒ©ć‚¦ć‚¶ćÆéŸ³å£°čŖč­˜ć‚’ć‚µćƒćƒ¼ćƒˆć—ć¦ć„ć¾ć›ć‚“", + "com_ui_speech_not_supported_use_external": "ćŠä½æć„ć®ćƒ–ćƒ©ć‚¦ć‚¶ćÆéŸ³å£°čŖč­˜ć‚’ć‚µćƒćƒ¼ćƒˆć—ć¦ć„ć¾ć›ć‚“ć€‚[設定] > [音声]恧[å¤–éƒØSTT]ć«åˆ‡ć‚Šę›æćˆć¦ćæć¦ćć ć•ć„ć€‚", "com_ui_speech_while_submitting": "åæœē­”ć®ē”Ÿęˆäø­ćÆéŸ³å£°ć‚’é€äæ”ć§ćć¾ć›ć‚“", "com_ui_sr_actions_menu": "{{0}}ć®ć‚¢ć‚Æć‚·ćƒ§ćƒ³ćƒ”ćƒ‹ćƒ„ćƒ¼ć‚’é–‹ć", + "com_ui_sr_global_prompt": "ć‚°ćƒ­ćƒ¼ćƒćƒ«ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚°ćƒ«ćƒ¼ćƒ—", + "com_ui_stack_trace": "ć‚¹ć‚æćƒƒć‚Æćƒˆćƒ¬ćƒ¼ć‚¹", + "com_ui_status_prefix": "ć‚¹ćƒ†ćƒ¼ć‚æć‚¹:", "com_ui_stop": "止める", "com_ui_storage": "ć‚¹ćƒˆćƒ¬ćƒ¼ć‚ø", + "com_ui_storage_filter_sort": "ć‚¹ćƒˆćƒ¬ćƒ¼ć‚øć«ć‚ˆć‚‹ćƒ•ć‚£ćƒ«ć‚æćƒŖćƒ³ć‚°ćØäø¦ć¹ę›æćˆ", "com_ui_submit": "送俔する", "com_ui_support_contact": "ć‚µćƒćƒ¼ćƒˆēŖ“å£", "com_ui_support_contact_email": "é›»å­ćƒ”ćƒ¼ćƒ«", @@ -1209,16 +1340,22 @@ "com_ui_terms_of_service": "åˆ©ē”Øč¦ē“„", "com_ui_thinking": "č€ƒćˆäø­...", "com_ui_thoughts": "ęŽØč«–", + "com_ui_toggle_theme": "ćƒ†ćƒ¼ćƒžć‚’åˆ‡ć‚Šę›æćˆć‚‹", "com_ui_token": "ćƒˆćƒ¼ć‚Æćƒ³", "com_ui_token_exchange_method": "ćƒˆćƒ¼ć‚Æćƒ³äŗ¤ę›ę–¹ę³•", "com_ui_token_url": "ćƒˆćƒ¼ć‚Æćƒ³URL", "com_ui_tokens": "ćƒˆćƒ¼ć‚Æćƒ³", "com_ui_tool_collection_prefix": "ćƒ„ćƒ¼ćƒ«ć®ć‚³ćƒ¬ć‚Æć‚·ćƒ§ćƒ³", + "com_ui_tool_list_collapse": "ꊘ悊恟恟悀 {{serverName}} ćƒ„ćƒ¼ćƒ«ćƒŖć‚¹ćƒˆ", + "com_ui_tool_list_expand": "展開 {{serverName}} ćƒ„ćƒ¼ćƒ«ćƒŖć‚¹ćƒˆ", "com_ui_tools": "ćƒ„ćƒ¼ćƒ«", + "com_ui_tools_and_actions": "ćƒ„ćƒ¼ćƒ«ćØć‚¢ć‚Æć‚·ćƒ§ćƒ³", "com_ui_transferred_to": "č»¢é€å…ˆ", "com_ui_travel": "ę—…č”Œ", "com_ui_trust_app": "ć“ć®ć‚¢ćƒ—ćƒŖć‚±ćƒ¼ć‚·ćƒ§ćƒ³ć‚’äæ”é ¼ć—ć¦ć„ć‚‹", "com_ui_try_adjusting_search": "ę¤œē“¢ę”ä»¶ć‚’čŖæę•“ć™ć‚‹", + "com_ui_ui_resource_error": "UI ćƒŖć‚½ćƒ¼ć‚¹ ć‚Øćƒ©ćƒ¼ ({{0}})", + "com_ui_ui_resource_not_found": "UI ćƒŖć‚½ćƒ¼ć‚¹ćŒč¦‹ć¤ć‹ć‚Šć¾ć›ć‚“ļ¼ˆindex: {{0}})", "com_ui_ui_resources": "UIćƒŖć‚½ćƒ¼ć‚¹", "com_ui_unarchive": "ć‚¢ćƒ¼ć‚«ć‚¤ćƒ–č§£é™¤", "com_ui_unarchive_error": "ć‚¢ćƒ¼ć‚«ć‚¤ćƒ–č§£é™¤ć«å¤±ę•—ć—ć¾ć—ćŸć€‚", @@ -1237,6 +1374,7 @@ "com_ui_upload_file_context": "ćƒ•ć‚”ć‚¤ćƒ«ć‚³ćƒ³ćƒ†ć‚­ć‚¹ćƒˆć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", "com_ui_upload_file_search": "ćƒ•ć‚”ć‚¤ćƒ«ę¤œē“¢ē”Øć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", "com_ui_upload_files": "ćƒ•ć‚”ć‚¤ćƒ«ć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", + "com_ui_upload_icon": "ć‚¢ć‚¤ć‚³ćƒ³ē”»åƒć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", "com_ui_upload_image": "ē”»åƒć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", "com_ui_upload_image_input": "ē”»åƒć‚’ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰", "com_ui_upload_invalid": "ć‚¢ćƒƒćƒ—ćƒ­ćƒ¼ćƒ‰ć«ē„”åŠ¹ćŖćƒ•ć‚”ć‚¤ćƒ«ć§ć™ć€‚åˆ¶é™ć‚’č¶…ćˆćŖć„ē”»åƒć§ć‚ć‚‹åæ…č¦ćŒć‚ć‚Šć¾ć™ć€‚", @@ -1253,6 +1391,7 @@ "com_ui_used": "ä½æē”Øęøˆćæ", "com_ui_user": "ćƒ¦ćƒ¼ć‚¶ćƒ¼", "com_ui_user_group_permissions": "ćƒ¦ćƒ¼ć‚¶ćƒ¼ćØć‚°ćƒ«ćƒ¼ćƒ—ć®ęØ©é™", + "com_ui_user_provides_key": "ćƒ¦ćƒ¼ć‚¶ćƒ¼ćÆå€‹äŗŗć‚­ćƒ¼ć‚’ē™»éŒ²ć™ć‚‹", "com_ui_value": "値", "com_ui_variables": "変数", "com_ui_variables_info": "ćƒ†ć‚­ć‚¹ćƒˆå†…ć§äŗŒé‡äø­ę‹¬å¼§ć‚’ä½æē”Øć—ć¦å¤‰ę•°ć‚’å®šē¾©ć—ć¾ć™ć€‚ä¾‹ćˆć°ć€`{{example variable}}`ć®ć‚ˆć†ć«ć™ć‚‹ćØć€ćƒ—ćƒ­ćƒ³ćƒ—ćƒˆć‚’ä½æē”Øć™ć‚‹ćØćć«å¾Œć§å€¤ć‚’åŸ‹ć‚č¾¼ć‚€ć“ćØćŒć§ćć¾ć™ć€‚", @@ -1289,6 +1428,7 @@ "com_ui_weekend_morning": "ę„½ć—ć„é€±ęœ«ć‚’", "com_ui_write": "åŸ·ē­†", "com_ui_x_selected": "{{0}}ćŒéøęŠžć•ć‚ŒćŸ", + "com_ui_xhigh": "ć‚Øć‚Æć‚¹ćƒˆćƒ©ćƒ»ćƒć‚¤", "com_ui_yes": "はい", "com_ui_zoom": "ć‚ŗćƒ¼ćƒ ", "com_ui_zoom_in": "ć‚ŗćƒ¼ćƒ ć‚¤ćƒ³", From cdffdd2926005f6b9009d7330eb139d378db2fcb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 09:01:23 -0500 Subject: [PATCH 010/282] =?UTF-8?q?=F0=9F=8F=9E=EF=B8=8F=20fix:=20Gemini?= =?UTF-8?q?=20Image=20Filenames=20and=20Add=20Tool=20Cache=20Safety=20(#11?= =?UTF-8?q?306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ fix: Handle undefined tool definitions in agent and assistant creation (#11295) * Updated the tool fetching logic in createAgentHandler, createAssistant, and patchAssistant functions to use nullish coalescing, ensuring that an empty object is returned if no tools are available. This change improves robustness against undefined values in tool definitions across multiple controller files. * Adjusted the ToolService to maintain consistency in tool definition handling. * šŸ”§ fix: Update filename generation in createToolEndCallback function * Modified the filename generation logic to remove the tool_call_id from the filename, simplifying the naming convention for saved images. This change enhances clarity and consistency in the generated filenames. --- api/server/controllers/agents/callbacks.js | 2 +- api/server/controllers/agents/v1.js | 2 +- api/server/controllers/assistants/v1.js | 4 ++-- api/server/controllers/assistants/v2.js | 4 ++-- api/server/services/ToolService.js | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index aee419577a..0d2a7bc317 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -408,7 +408,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) const { url } = part.image_url; artifactPromises.push( (async () => { - const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`; + const filename = `${output.name}_img_${nanoid()}`; const file_id = output.artifact.file_ids?.[i]; const file = await saveBase64Image(url, { req, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5c2ac8bb06..19a185279e 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -119,7 +119,7 @@ const createAgentHandler = async (req, res) => { agentData.author = userId; agentData.tools = []; - const availableTools = await getCachedTools(); + const availableTools = (await getCachedTools()) ?? {}; for (const tool of tools) { if (availableTools[tool]) { agentData.tools.push(tool); diff --git a/api/server/controllers/assistants/v1.js b/api/server/controllers/assistants/v1.js index 32842deb0f..5d13d30334 100644 --- a/api/server/controllers/assistants/v1.js +++ b/api/server/controllers/assistants/v1.js @@ -31,7 +31,7 @@ const createAssistant = async (req, res) => { delete assistantData.conversation_starters; delete assistantData.append_current_datetime; - const toolDefinitions = await getCachedTools(); + const toolDefinitions = (await getCachedTools()) ?? {}; assistantData.tools = tools .map((tool) => { @@ -136,7 +136,7 @@ const patchAssistant = async (req, res) => { ...updateData } = req.body; - const toolDefinitions = await getCachedTools(); + const toolDefinitions = (await getCachedTools()) ?? {}; updateData.tools = (updateData.tools ?? []) .map((tool) => { diff --git a/api/server/controllers/assistants/v2.js b/api/server/controllers/assistants/v2.js index 278dd13021..b9c5cd709f 100644 --- a/api/server/controllers/assistants/v2.js +++ b/api/server/controllers/assistants/v2.js @@ -28,7 +28,7 @@ const createAssistant = async (req, res) => { delete assistantData.conversation_starters; delete assistantData.append_current_datetime; - const toolDefinitions = await getCachedTools(); + const toolDefinitions = (await getCachedTools()) ?? {}; assistantData.tools = tools .map((tool) => { @@ -125,7 +125,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => { let hasFileSearch = false; for (const tool of updateData.tools ?? []) { - const toolDefinitions = await getCachedTools(); + const toolDefinitions = (await getCachedTools()) ?? {}; let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool; if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) { diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 1e2074cdf4..62d25b23eb 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -79,7 +79,7 @@ async function processRequiredActions(client, requiredActions) { requiredActions, ); const appConfig = client.req.config; - const toolDefinitions = await getCachedTools(); + const toolDefinitions = (await getCachedTools()) ?? {}; const seenToolkits = new Set(); const tools = requiredActions .map((action) => { From fc6f127b2138d89f90a91fe9a7feb7baa1a253c4 Mon Sep 17 00:00:00 2001 From: Joseph Licata <54822374+usnavy13@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:51:48 -0500 Subject: [PATCH 011/282] =?UTF-8?q?=F0=9F=8C=89=20fix:=20Add=20Proxy=20Sup?= =?UTF-8?q?port=20to=20Gemini=20Image=20Gen=20Tool=20(#11302)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add proxy support for Google APIs in GeminiImageGen - Implemented a proxy wrapper for globalThis.fetch to route requests to googleapis.com through a specified proxy. - Added tests to verify the proxy configuration behavior, ensuring correct dispatcher application for Google API calls and preserving existing options. Co-authored-by: [Your Name] * chore: remove comment --------- Co-authored-by: [Your Name] Co-authored-by: Danny Avila --- .../tools/structured/GeminiImageGen.js | 19 +++ .../specs/GeminiImageGen-proxy.spec.js | 125 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index c6ee58a61e..c0e5a0ce1d 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { v4 } = require('uuid'); +const { ProxyAgent } = require('undici'); const { GoogleGenAI } = require('@google/genai'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); @@ -21,6 +22,24 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { spendTokens } = require('~/models/spendTokens'); const { getFiles } = require('~/models/File'); +/** + * Configure proxy support for Google APIs + * This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs + * This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher + */ +if (process.env.PROXY) { + const originalFetch = globalThis.fetch; + const proxyAgent = new ProxyAgent(process.env.PROXY); + + globalThis.fetch = function (url, options = {}) { + const urlString = url.toString(); + if (urlString.includes('googleapis.com')) { + options = { ...options, dispatcher: proxyAgent }; + } + return originalFetch.call(this, url, options); + }; +} + /** * Get the default service key file path (consistent with main Google endpoint) * @returns {string} - The default path to the service key file diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js new file mode 100644 index 0000000000..027d2659d6 --- /dev/null +++ b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js @@ -0,0 +1,125 @@ +const { ProxyAgent } = require('undici'); + +/** + * These tests verify the proxy wrapper behavior for GeminiImageGen. + * Instead of loading the full module (which has many dependencies), + * we directly test the wrapper logic that would be applied. + */ +describe('GeminiImageGen Proxy Configuration', () => { + let originalEnv; + let originalFetch; + + beforeAll(() => { + originalEnv = { ...process.env }; + originalFetch = globalThis.fetch; + }); + + beforeEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + }); + + /** + * Simulates the proxy wrapper that GeminiImageGen applies at module load. + * This is the same logic from GeminiImageGen.js lines 30-42. + */ + function applyProxyWrapper() { + if (process.env.PROXY) { + const _originalFetch = globalThis.fetch; + const proxyAgent = new ProxyAgent(process.env.PROXY); + + globalThis.fetch = function (url, options = {}) { + const urlString = url.toString(); + if (urlString.includes('googleapis.com')) { + options = { ...options, dispatcher: proxyAgent }; + } + return _originalFetch.call(this, url, options); + }; + } + } + + it('should wrap globalThis.fetch when PROXY env is set', () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + const fetchBeforeWrap = globalThis.fetch; + + applyProxyWrapper(); + + expect(globalThis.fetch).not.toBe(fetchBeforeWrap); + }); + + it('should not wrap globalThis.fetch when PROXY env is not set', () => { + delete process.env.PROXY; + + const fetchBeforeWrap = globalThis.fetch; + + applyProxyWrapper(); + + expect(globalThis.fetch).toBe(fetchBeforeWrap); + }); + + it('should add dispatcher to googleapis.com URLs', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {}); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + }); + + it('should not add dispatcher to non-googleapis.com URLs', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + await globalThis.fetch('https://api.openai.com/v1/images', {}); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeUndefined(); + }); + + it('should preserve existing options when adding dispatcher', async () => { + process.env.PROXY = 'http://proxy.example.com:8080'; + + let capturedOptions = null; + const mockFetch = jest.fn((url, options) => { + capturedOptions = options; + return Promise.resolve({ ok: true }); + }); + globalThis.fetch = mockFetch; + + applyProxyWrapper(); + + const customHeaders = { 'X-Custom-Header': 'test' }; + await globalThis.fetch('https://aiplatform.googleapis.com/v1/models', { + headers: customHeaders, + method: 'POST', + }); + + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + expect(capturedOptions.headers).toEqual(customHeaders); + expect(capturedOptions.method).toBe('POST'); + }); +}); From 90521bfb4ee843115fc6c46edabb4ab22f102b2f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 19:01:45 -0500 Subject: [PATCH 012/282] =?UTF-8?q?=F0=9F=A7=B9=20fix:=20MCP=20Panel=20Reg?= =?UTF-8?q?ressions=20after=20UI=20refactor=20(#11312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Revoke OAuth and Vars. Config Regressions in MCP Panel - Introduced a new Trash2 icon button in MCPCardActions for revoking OAuth access on connected OAuth servers. - Updated MCPServerCard to handle the revoke action, allowing users to revoke OAuth for specific servers. - Enhanced user experience by ensuring the revoke option is available regardless of the server's connection state. * refactor: Reorganize Revoke Button Logic in MCPCardActions and Update Toast Messages - Moved the Revoke button for OAuth servers to a new position in MCPCardActions for improved visibility. - Updated the success message logic in useMCPServerManager to differentiate between uninstall and variable update actions, enhancing user feedback. * i18n: Add new translation for MCP server access revocation message * refactor: Centralize Deselection Logic in updateUserPluginsMutation - Updated the success handler in useUpdateUserPluginsMutation to manage deselection of MCP server values when revoking access, improving code clarity and reducing redundancy. - Simplified message assignment logic for user feedback during plugin updates. --- .../SidePanel/MCPBuilder/MCPCardActions.tsx | 19 ++++++++++++++- .../SidePanel/MCPBuilder/MCPServerCard.tsx | 14 ++++++++++- client/src/hooks/MCP/useMCPServerManager.ts | 23 +++++++++++++------ client/src/locales/en/translation.json | 1 + 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx index 015dfce014..7185185132 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pencil, PlugZap, SlidersHorizontal, RefreshCw, X } from 'lucide-react'; +import { Pencil, PlugZap, SlidersHorizontal, RefreshCw, X, Trash2 } from 'lucide-react'; import { Spinner, TooltipAnchor } from '@librechat/client'; import type { MCPServerStatus } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; @@ -17,6 +17,7 @@ interface MCPCardActionsProps { onConfigClick: (e: React.MouseEvent) => void; onInitialize: () => void; onCancel: (e: React.MouseEvent) => void; + onRevoke?: () => void; } /** @@ -26,6 +27,7 @@ interface MCPCardActionsProps { * - Pencil: Edit server definition (Settings panel only) * - PlugZap: Connect/Authenticate (for disconnected/error servers) * - SlidersHorizontal: Configure custom variables (for connected servers with vars) + * - Trash2: Revoke OAuth access (for connected OAuth servers) * - RefreshCw: Reconnect/Refresh (for connected servers) * - Spinner: Loading state (with X on hover for cancel) */ @@ -41,6 +43,7 @@ export default function MCPCardActions({ onConfigClick, onInitialize, onCancel, + onRevoke, }: MCPCardActionsProps) { const localize = useLocalize(); @@ -162,6 +165,20 @@ export default function MCPCardActions({
); } diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx index 34f98b34d3..1e538724fb 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx @@ -30,7 +30,7 @@ export default function MCPServerCard({ }: MCPServerCardProps) { const localize = useLocalize(); const triggerRef = useRef(null); - const { initializeServer } = useMCPServerManager(); + const { initializeServer, revokeOAuthForServer } = useMCPServerManager(); const [dialogOpen, setDialogOpen] = useState(false); const statusIconProps = getServerStatusIconProps(server.serverName); @@ -50,9 +50,20 @@ export default function MCPServerCard({ const canEdit = canCreateEditMCPs && canEditThisServer; const handleInitialize = () => { + /** If server has custom user vars and is not already connected, show config dialog first + * This ensures users can enter credentials before initialization attempts + */ + if (hasCustomUserVars && serverStatus?.connectionState !== 'connected') { + onConfigClick({ stopPropagation: () => {}, preventDefault: () => {} } as React.MouseEvent); + return; + } initializeServer(server.serverName); }; + const handleRevoke = () => { + revokeOAuthForServer(server.serverName); + }; + const handleEditClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -130,6 +141,7 @@ export default function MCPServerCard({ onConfigClick={onConfigClick} onInitialize={handleInitialize} onCancel={onCancel} + onRevoke={handleRevoke} />
diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index d3ff4cbb70..bb5214be7c 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -94,8 +94,20 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const cancelOAuthMutation = useCancelMCPOAuthMutation(); const updateUserPluginsMutation = useUpdateUserPluginsMutation({ - onSuccess: async () => { - showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); + onSuccess: async (_data, variables) => { + const isRevoke = variables.action === 'uninstall'; + const message = isRevoke + ? localize('com_nav_mcp_access_revoked') + : localize('com_nav_mcp_vars_updated'); + showToast({ message, status: 'success' }); + + /** Deselect server from mcpValues when revoking access */ + if (isRevoke && variables.pluginKey?.startsWith(Constants.mcp_prefix)) { + const serverName = variables.pluginKey.replace(Constants.mcp_prefix, ''); + const currentValues = mcpValuesRef.current ?? []; + const filteredValues = currentValues.filter((name) => name !== serverName); + setMCPValues(filteredValues); + } await Promise.all([ queryClient.invalidateQueries([QueryKeys.mcpServers]), @@ -491,13 +503,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin auth: {}, }; updateUserPluginsMutation.mutate(payload); - - const currentValues = mcpValues ?? []; - const filteredValues = currentValues.filter((name) => name !== targetName); - setMCPValues(filteredValues); + /** Deselection is now handled centrally in updateUserPluginsMutation.onSuccess */ } }, - [selectedToolForConfig, updateUserPluginsMutation, mcpValues, setMCPValues], + [selectedToolForConfig, updateUserPluginsMutation], ); /** Standalone revoke function for OAuth servers - doesn't require selectedToolForConfig */ diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index aba6ea9fb0..cd1f5991f5 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -546,6 +546,7 @@ "com_nav_mcp_status_unknown": "Unknown", "com_nav_mcp_vars_update_error": "Error updating MCP custom user variables", "com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.", + "com_nav_mcp_access_revoked": "MCP server access revoked successfully.", "com_nav_modular_chat": "Enable switching Endpoints mid-conversation", "com_nav_my_files": "My Files", "com_nav_not_supported": "Not Supported", From 28270bec58a24c218141378887ce62e9f407724e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 19:12:36 -0500 Subject: [PATCH 013/282] =?UTF-8?q?=F0=9F=8C=B5=20chore:=20Remove=20deprec?= =?UTF-8?q?ated=20'prompt-caching'=20Anthropic=20header=20(#11313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/translations/anthropic.ts | 3 -- .../api/src/endpoints/anthropic/helpers.ts | 21 +++------- .../api/src/endpoints/anthropic/llm.spec.ts | 42 +++++++------------ .../api/src/endpoints/anthropic/vertex.ts | 2 +- .../endpoints/openai/config.anthropic.spec.ts | 27 +++--------- 5 files changed, 27 insertions(+), 68 deletions(-) diff --git a/config/translations/anthropic.ts b/config/translations/anthropic.ts index a31c45ffed..968474a45a 100644 --- a/config/translations/anthropic.ts +++ b/config/translations/anthropic.ts @@ -10,9 +10,6 @@ export function getClient() { /** @type {Anthropic.default.RequestOptions} */ const options = { apiKey: process.env.ANTHROPIC_API_KEY, - defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, }; return new Anthropic(options); diff --git a/packages/api/src/endpoints/anthropic/helpers.ts b/packages/api/src/endpoints/anthropic/helpers.ts index ae199ce89b..0596c1efcc 100644 --- a/packages/api/src/endpoints/anthropic/helpers.ts +++ b/packages/api/src/endpoints/anthropic/helpers.ts @@ -42,30 +42,19 @@ function getClaudeHeaders( if (/claude-3[-.]5-sonnet/.test(model)) { return { - 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31', + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', }; } else if (/claude-3[-.]7/.test(model)) { return { - 'anthropic-beta': - 'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31', + 'anthropic-beta': 'token-efficient-tools-2025-02-19,output-128k-2025-02-19', }; } else if (/claude-sonnet-4/.test(model)) { return { - 'anthropic-beta': 'prompt-caching-2024-07-31,context-1m-2025-08-07', - }; - } else if ( - /claude-(?:sonnet|opus|haiku)-[4-9]/.test(model) || - /claude-[4-9]-(?:sonnet|opus|haiku)?/.test(model) || - /claude-4(?:-(?:sonnet|opus|haiku))?/.test(model) - ) { - return { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }; - } else { - return { - 'anthropic-beta': 'prompt-caching-2024-07-31', + 'anthropic-beta': 'context-1m-2025-08-07', }; } + + return undefined; } /** diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 5c220d2dc7..0e457b60c2 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -87,7 +87,7 @@ describe('getLLMConfig', () => { expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000); }); - it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model', () => { + it('should add "context-1m" beta header for claude-sonnet-4 model', () => { const modelOptions = { model: 'claude-sonnet-4-20250514', promptCache: true, @@ -97,12 +97,10 @@ describe('getLLMConfig', () => { expect(clientOptions?.defaultHeaders).toBeDefined(); expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); const defaultHeaders = clientOptions?.defaultHeaders as Record; - expect(defaultHeaders['anthropic-beta']).toBe( - 'prompt-caching-2024-07-31,context-1m-2025-08-07', - ); + expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); }); - it('should add "prompt-caching" and "context-1m" beta headers for claude-sonnet-4 model formats', () => { + it('should add "context-1m" beta header for claude-sonnet-4 model formats', () => { const modelVariations = [ 'claude-sonnet-4-20250514', 'claude-sonnet-4-latest', @@ -116,26 +114,21 @@ describe('getLLMConfig', () => { expect(clientOptions?.defaultHeaders).toBeDefined(); expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); const defaultHeaders = clientOptions?.defaultHeaders as Record; - expect(defaultHeaders['anthropic-beta']).toBe( - 'prompt-caching-2024-07-31,context-1m-2025-08-07', - ); + expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); }); }); - it('should add "prompt-caching" beta header for claude-opus-4-5 model', () => { + it('should not add beta headers for claude-opus-4-5 model (prompt caching no longer needs header)', () => { const modelOptions = { model: 'claude-opus-4-5', promptCache: true, }; const result = getLLMConfig('test-key', { modelOptions }); const clientOptions = result.llmConfig.clientOptions; - expect(clientOptions?.defaultHeaders).toBeDefined(); - expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); - const defaultHeaders = clientOptions?.defaultHeaders as Record; - expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31'); + expect(clientOptions?.defaultHeaders).toBeUndefined(); }); - it('should add "prompt-caching" beta header for claude-opus-4-5 model formats', () => { + it('should not add beta headers for claude-opus-4-5 model formats (prompt caching no longer needs header)', () => { const modelVariations = [ 'claude-opus-4-5', 'claude-opus-4-5-20250420', @@ -147,10 +140,7 @@ describe('getLLMConfig', () => { const modelOptions = { model, promptCache: true }; const result = getLLMConfig('test-key', { modelOptions }); const clientOptions = result.llmConfig.clientOptions; - expect(clientOptions?.defaultHeaders).toBeDefined(); - expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); - const defaultHeaders = clientOptions?.defaultHeaders as Record; - expect(defaultHeaders['anthropic-beta']).toBe('prompt-caching-2024-07-31'); + expect(clientOptions?.defaultHeaders).toBeUndefined(); }); }); @@ -309,9 +299,9 @@ describe('getLLMConfig', () => { }, }); - // claude-3-5-sonnet supports prompt caching and should get the appropriate headers + // claude-3-5-sonnet supports prompt caching and should get the max-tokens header expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({ - 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31', + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', }); }); @@ -520,8 +510,7 @@ describe('getLLMConfig', () => { expect(result.llmConfig).not.toHaveProperty('topK'); // Should have appropriate headers for Claude-3.7 with prompt cache expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({ - 'anthropic-beta': - 'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31', + 'anthropic-beta': 'token-efficient-tools-2025-02-19,output-128k-2025-02-19', }); }); @@ -1170,13 +1159,14 @@ describe('getLLMConfig', () => { it('should handle prompt cache support logic for different models', () => { const testCases = [ - // Models that support prompt cache + // Models that support prompt cache (and have other beta headers) { model: 'claude-3-5-sonnet', promptCache: true, shouldHaveHeaders: true }, { model: 'claude-3.5-sonnet-20241022', promptCache: true, shouldHaveHeaders: true }, { model: 'claude-3-7-sonnet', promptCache: true, shouldHaveHeaders: true }, { model: 'claude-3.7-sonnet-20250109', promptCache: true, shouldHaveHeaders: true }, - { model: 'claude-3-opus', promptCache: true, shouldHaveHeaders: true }, { model: 'claude-sonnet-4-20250514', promptCache: true, shouldHaveHeaders: true }, + // Models that support prompt cache but have no additional beta headers needed + { model: 'claude-3-opus', promptCache: true, shouldHaveHeaders: false }, // Models that don't support prompt cache { model: 'claude-3-5-sonnet-latest', promptCache: true, shouldHaveHeaders: false }, { model: 'claude-3.5-sonnet-latest', promptCache: true, shouldHaveHeaders: false }, @@ -1193,9 +1183,7 @@ describe('getLLMConfig', () => { if (shouldHaveHeaders) { expect(headers).toBeDefined(); - expect((headers as Record)['anthropic-beta']).toContain( - 'prompt-caching', - ); + expect((headers as Record)['anthropic-beta']).toBeDefined(); } else { expect(headers).toBeUndefined(); } diff --git a/packages/api/src/endpoints/anthropic/vertex.ts b/packages/api/src/endpoints/anthropic/vertex.ts index 116a00875f..12967f1a58 100644 --- a/packages/api/src/endpoints/anthropic/vertex.ts +++ b/packages/api/src/endpoints/anthropic/vertex.ts @@ -79,7 +79,7 @@ export function isAnthropicVertexCredentials(credentials: AnthropicCredentials): /** * Filters anthropic-beta header values to only include those supported by Vertex AI. - * Vertex AI rejects prompt-caching-2024-07-31 but we use 'prompt-caching-vertex' as a + * Vertex AI handles caching differently and we use 'prompt-caching-vertex' as a * marker to trigger cache_control application in the agents package. */ function filterVertexHeaders(headers?: Record): Record | undefined { diff --git a/packages/api/src/endpoints/openai/config.anthropic.spec.ts b/packages/api/src/endpoints/openai/config.anthropic.spec.ts index 7cc8240031..eeb17a311d 100644 --- a/packages/api/src/endpoints/openai/config.anthropic.spec.ts +++ b/packages/api/src/endpoints/openai/config.anthropic.spec.ts @@ -44,7 +44,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { configOptions: { baseURL: 'http://host.docker.internal:4000/v1', defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31,context-1m-2025-08-07', + 'anthropic-beta': 'context-1m-2025-08-07', }, }, tools: [], @@ -92,8 +92,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { configOptions: { baseURL: 'http://localhost:4000/v1', defaultHeaders: { - 'anthropic-beta': - 'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31', + 'anthropic-beta': 'token-efficient-tools-2025-02-19,output-128k-2025-02-19', }, }, tools: [], @@ -140,8 +139,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { configOptions: { baseURL: 'http://localhost:4000/v1', defaultHeaders: { - 'anthropic-beta': - 'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31', + 'anthropic-beta': 'token-efficient-tools-2025-02-19,output-128k-2025-02-19', }, }, tools: [], @@ -182,14 +180,14 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { configOptions: { baseURL: 'https://api.anthropic.proxy.com/v1', defaultHeaders: { - 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31', + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', }, }, tools: [], }); }); - it('should apply anthropic-beta headers based on model pattern', () => { + it('should apply custom headers without anthropic-beta for models that dont need it', () => { const apiKey = 'sk-custom'; const endpoint = 'Anthropic (via LiteLLM)'; const options = { @@ -227,7 +225,6 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { defaultHeaders: { 'Custom-Header': 'custom-value', Authorization: 'Bearer custom-token', - 'anthropic-beta': 'prompt-caching-2024-07-31', }, }, tools: [], @@ -309,9 +306,6 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { }, configOptions: { baseURL: 'http://proxy.litellm/v1', - defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, }, tools: [], }); @@ -389,9 +383,6 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { }, configOptions: { baseURL: 'http://litellm/v1', - defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, }, tools: [ { @@ -438,9 +429,6 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { }, configOptions: { baseURL: 'http://litellm/v1', - defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, }, tools: [], }); @@ -488,9 +476,6 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { }, configOptions: { baseURL: 'http://litellm/v1', - defaultHeaders: { - 'anthropic-beta': 'prompt-caching-2024-07-31', - }, }, tools: [], }); @@ -541,7 +526,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { configOptions: { baseURL: 'http://litellm/v1', defaultHeaders: { - 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15,prompt-caching-2024-07-31', + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', }, }, tools: [], From a8fa85b8e2e9b82925a8c57f13325216262f8db5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 20:11:34 -0500 Subject: [PATCH 014/282] =?UTF-8?q?=F0=9F=93=9C=20fix:=20Layout/Overflow?= =?UTF-8?q?=20handling=20in=20Share=20View=20(#11314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated MessagesView to include min-height and overflow-hidden for better layout management. - Adjusted ShareView to ensure proper height and overflow handling, enhancing the overall user experience. --- client/src/components/Share/MessagesView.tsx | 2 +- client/src/components/Share/ShareView.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/Share/MessagesView.tsx b/client/src/components/Share/MessagesView.tsx index 2d11051688..9fa4276024 100644 --- a/client/src/components/Share/MessagesView.tsx +++ b/client/src/components/Share/MessagesView.tsx @@ -13,7 +13,7 @@ export default function MessagesView({ const localize = useLocalize(); const [currentEditId, setCurrentEditId] = useState(-1); return ( -
+
-
+
{content} {footer}
@@ -150,7 +150,7 @@ function SharedView() { return ( -
+
{artifactsContainer}
From f8774983a0c5fd33ac11562a93ad26f97b862901 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 21:04:25 -0500 Subject: [PATCH 015/282] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Misleading=20MCP?= =?UTF-8?q?=20Server=20Lookup=20Method=20Name=20(#11315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ fix: MCP server ID resolver in access permissions (#11315) - Replaced `findMCPServerById` with `findMCPServerByObjectId` in access permissions route and corresponding tests for improved clarity and consistency in resource identification. * šŸ”§ refactor: Update MCP server resource access methods to use server name - Replaced instances of `findMCPServerById` with `findMCPServerByServerName` across middleware, database, and test files for improved clarity and consistency in resource identification. - Updated related comments and test cases to reflect the change in method usage. * chore: Increase timeout for Redis update in GenerationJobManager integration tests - Updated the timeout duration from 50ms to 200ms in the GenerationJobManager integration tests to ensure reliable verification of final event data in Redis after emitting the done event. --- .../canAccessMCPServerResource.js | 14 +++++++------- .../canAccessMCPServerResource.spec.js | 2 +- api/server/routes/accessPermissions.js | 4 ++-- api/server/routes/accessPermissions.test.js | 4 ++-- .../api/src/mcp/registry/db/ServerConfigsDB.ts | 4 ++-- ...nerationJobManager.stream_integration.spec.ts | 3 +-- .../data-schemas/src/methods/mcpServer.spec.ts | 16 ++++++++-------- packages/data-schemas/src/methods/mcpServer.ts | 6 +++--- 8 files changed, 26 insertions(+), 27 deletions(-) diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.js b/api/server/middleware/accessResources/canAccessMCPServerResource.js index 69f5f4e4f6..85d479c712 100644 --- a/api/server/middleware/accessResources/canAccessMCPServerResource.js +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.js @@ -1,16 +1,16 @@ const { ResourceType } = require('librechat-data-provider'); const { canAccessResource } = require('./canAccessResource'); -const { findMCPServerById } = require('~/models'); +const { findMCPServerByServerName } = require('~/models'); /** - * MCP Server ID resolver function - * Resolves custom MCP server ID (e.g., "mcp_abc123") to MongoDB ObjectId + * MCP Server name resolver function + * Resolves MCP server name (e.g., "my-mcp-server") to MongoDB ObjectId * - * @param {string} mcpServerCustomId - Custom MCP server ID from route parameter + * @param {string} serverName - Server name from route parameter * @returns {Promise} MCP server document with _id field, or null if not found */ -const resolveMCPServerId = async (mcpServerCustomId) => { - return await findMCPServerById(mcpServerCustomId); +const resolveMCPServerName = async (serverName) => { + return await findMCPServerByServerName(serverName); }; /** @@ -52,7 +52,7 @@ const canAccessMCPServerResource = (options) => { resourceType: ResourceType.MCPSERVER, requiredPermission, resourceIdParam, - idResolver: resolveMCPServerId, + idResolver: resolveMCPServerName, }); }; diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js index 075cddb000..77508be2d1 100644 --- a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js @@ -545,7 +545,7 @@ describe('canAccessMCPServerResource middleware', () => { describe('error handling', () => { test('should handle server returning null gracefully (treated as not found)', async () => { - // When an MCP server is not found, findMCPServerById returns null + // When an MCP server is not found, findMCPServerByServerName returns null // which the middleware correctly handles as a 404 req.params.serverName = 'definitely-non-existent-server'; diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 2cfd19289d..79e7f3ddca 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -11,7 +11,7 @@ const { const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess'); const { checkSharePublicAccess } = require('~/server/middleware/checkSharePublicAccess'); -const { findMCPServerById } = require('~/models'); +const { findMCPServerByObjectId } = require('~/models'); const router = express.Router(); @@ -64,7 +64,7 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) = resourceType: ResourceType.MCPSERVER, requiredPermission, resourceIdParam: 'resourceId', - idResolver: findMCPServerById, + idResolver: findMCPServerByObjectId, }); } else { return res.status(400).json({ diff --git a/api/server/routes/accessPermissions.test.js b/api/server/routes/accessPermissions.test.js index c7e7b5957c..81c21c8667 100644 --- a/api/server/routes/accessPermissions.test.js +++ b/api/server/routes/accessPermissions.test.js @@ -32,7 +32,7 @@ jest.mock('~/server/middleware/checkPeoplePickerAccess', () => ({ // Import actual middleware to get canAccessResource const { canAccessResource } = require('~/server/middleware'); -const { findMCPServerById } = require('~/models'); +const { findMCPServerByObjectId } = require('~/models'); /** * Security Tests for SBA-ADV-20251203-02 @@ -151,7 +151,7 @@ describe('Access Permissions Routes - Security Tests (SBA-ADV-20251203-02)', () resourceType: ResourceType.MCPSERVER, requiredPermission, resourceIdParam: 'resourceId', - idResolver: findMCPServerById, + idResolver: findMCPServerByObjectId, }); } else { return res.status(400).json({ diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index e2eb8d6974..969969a9f6 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -134,7 +134,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { ); } - const existingServer = await this._dbMethods.findMCPServerById(serverName); + const existingServer = await this._dbMethods.findMCPServerByServerName(serverName); let configToSave: ParsedServerConfig = { ...config }; // Transform user-provided API key config (adds customUserVars and headers) @@ -204,7 +204,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { * @returns The parsed server config or undefined if not found. If accessed via agent, consumeOnly will be true. */ public async get(serverName: string, userId?: string): Promise { - const server = await this._dbMethods.findMCPServerById(serverName); + const server = await this._dbMethods.findMCPServerByServerName(serverName); if (!server) return undefined; // Check public access if no userId diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts index d95376f782..4471d8c95d 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts @@ -584,8 +584,7 @@ describe('GenerationJobManager Integration Tests', () => { }; GenerationJobManager.emitDone(streamId, finalEventData as never); - // Wait for async Redis update - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 200)); // Verify finalEvent is in Redis const jobStore = services.jobStore; diff --git a/packages/data-schemas/src/methods/mcpServer.spec.ts b/packages/data-schemas/src/methods/mcpServer.spec.ts index 387a697ffc..66f0982438 100644 --- a/packages/data-schemas/src/methods/mcpServer.spec.ts +++ b/packages/data-schemas/src/methods/mcpServer.spec.ts @@ -228,22 +228,22 @@ describe('MCPServer Model Tests', () => { }); }); - describe('findMCPServerById', () => { + describe('findMCPServerByServerName', () => { test('should find server by serverName', async () => { const created = await methods.createMCPServer({ - config: createSSEConfig('Find By Id Test'), + config: createSSEConfig('Find By Name Test'), author: authorId, }); - const found = await methods.findMCPServerById(created.serverName); + const found = await methods.findMCPServerByServerName(created.serverName); expect(found).toBeDefined(); - expect(found?.serverName).toBe('find-by-id-test'); - expect(found?.config.title).toBe('Find By Id Test'); + expect(found?.serverName).toBe('find-by-name-test'); + expect(found?.config.title).toBe('Find By Name Test'); }); test('should return null when server not found', async () => { - const found = await methods.findMCPServerById('non-existent-server'); + const found = await methods.findMCPServerByServerName('non-existent-server'); expect(found).toBeNull(); }); @@ -254,7 +254,7 @@ describe('MCPServer Model Tests', () => { author: authorId, }); - const found = await methods.findMCPServerById('lean-test'); + const found = await methods.findMCPServerByServerName('lean-test'); // Lean documents don't have mongoose methods expect(found).toBeDefined(); @@ -621,7 +621,7 @@ describe('MCPServer Model Tests', () => { expect(deleted?.serverName).toBe('delete-test'); // Verify it's actually deleted - const found = await methods.findMCPServerById('delete-test'); + const found = await methods.findMCPServerByServerName('delete-test'); expect(found).toBeNull(); }); diff --git a/packages/data-schemas/src/methods/mcpServer.ts b/packages/data-schemas/src/methods/mcpServer.ts index dd7876f03e..e85421fea2 100644 --- a/packages/data-schemas/src/methods/mcpServer.ts +++ b/packages/data-schemas/src/methods/mcpServer.ts @@ -134,10 +134,10 @@ export function createMCPServerMethods(mongoose: typeof import('mongoose')) { /** * Find an MCP server by serverName - * @param serverName - The MCP server ID + * @param serverName - The unique server name identifier * @returns The MCP server document or null */ - async function findMCPServerById(serverName: string): Promise { + async function findMCPServerByServerName(serverName: string): Promise { const MCPServer = mongoose.models.MCPServer as Model; return await MCPServer.findOne({ serverName }).lean(); } @@ -311,7 +311,7 @@ export function createMCPServerMethods(mongoose: typeof import('mongoose')) { return { createMCPServer, - findMCPServerById, + findMCPServerByServerName, findMCPServerByObjectId, findMCPServersByAuthor, getListMCPServersByIds, From 1329e16d3a9e216e6f29b34ce6058df7355b822a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:01:02 -0500 Subject: [PATCH 016/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/en/translation.json | 2 +- client/src/locales/lv/translation.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index cd1f5991f5..cd3f3151c7 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -533,6 +533,7 @@ "com_nav_log_out": "Log out", "com_nav_long_audio_warning": "Longer texts will take longer to process.", "com_nav_maximize_chat_space": "Maximize chat space", + "com_nav_mcp_access_revoked": "MCP server access revoked successfully.", "com_nav_mcp_configure_server": "Configure {{0}}", "com_nav_mcp_connect": "Connect", "com_nav_mcp_connect_server": "Connect {{0}}", @@ -546,7 +547,6 @@ "com_nav_mcp_status_unknown": "Unknown", "com_nav_mcp_vars_update_error": "Error updating MCP custom user variables", "com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.", - "com_nav_mcp_access_revoked": "MCP server access revoked successfully.", "com_nav_modular_chat": "Enable switching Endpoints mid-conversation", "com_nav_my_files": "My Files", "com_nav_not_supported": "Not Supported", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 4beb0815c8..f123294a8d 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -634,7 +634,9 @@ "com_ui_active": "AktÄ«vais", "com_ui_add": "Pievienot", "com_ui_add_code_interpreter_api_key": "Pievienot kodu tulkoÅ”anas API atslēgu", + "com_ui_add_first_bookmark": "NoklikŔķiniet uz sarunas, lai to pievienotu", "com_ui_add_first_mcp_server": "Izveidojiet savu pirmo MCP serveri, lai sāktu darbu", + "com_ui_add_first_prompt": "Izveidojiet savu pirmo uzvedni, lai sāktu darbu", "com_ui_add_mcp": "Pievienot MCP", "com_ui_add_mcp_server": "Pievienot MCP serveri", "com_ui_add_model_preset": "Pievienot modeli vai iestatÄ«jumu papildu atbildei", @@ -697,6 +699,7 @@ "com_ui_agents": "AÄ£enti", "com_ui_agents_allow_create": "Atļaut aÄ£entu izveidi", "com_ui_agents_allow_share": "Atļaut aÄ£entu kopÄ«got", + "com_ui_agents_allow_share_public": "Atļaut koplietot aÄ£entus publiski", "com_ui_agents_allow_use": "Atļaut aÄ£entu izmantoÅ”anu", "com_ui_all": "visu", "com_ui_all_proper": "Visi", @@ -1088,6 +1091,7 @@ "com_ui_mcp_servers": "MCP serveri", "com_ui_mcp_servers_allow_create": "Atļaut lietotājiem izveidot MCP serverus", "com_ui_mcp_servers_allow_share": "Atļaut lietotājiem koplietot MCP serverus", + "com_ui_mcp_servers_allow_share_public": "Ä»aujiet lietotājiem publiski koplietot MCP serverus", "com_ui_mcp_servers_allow_use": "Atļaut lietotājiem izmantot MCP serverus", "com_ui_mcp_title_invalid": "Virsrakstā var bÅ«t tikai burti, cipari un atstarpes.", "com_ui_mcp_transport": "Transports", @@ -1139,6 +1143,7 @@ "com_ui_no_auth": "Nav autorizācijas", "com_ui_no_bookmarks": "Å Ä·iet, ka jums vēl nav grāmatzÄ«mju. NoklikŔķiniet uz sarunas un pievienojiet jaunu.", "com_ui_no_bookmarks_match": "Nav atbilstoÅ”u grāmatzÄ«mju meklēŔanas vaicājumam", + "com_ui_no_bookmarks_title": "Vēl nav nevienas grāmatzÄ«mes", "com_ui_no_categories": "Nav pieejamas nevienas kategorijas", "com_ui_no_category": "Nav kategorijas", "com_ui_no_changes": "Izmaiņas netika veiktas", @@ -1149,6 +1154,7 @@ "com_ui_no_memories_match": "Nav atmiņu, kas atbilstu jÅ«su meklēŔanas vaicājumam", "com_ui_no_memories_title": "Vēl nav atmiņu", "com_ui_no_personalization_available": "PaÅ”laik nav pieejamas personalizācijas opcijas", + "com_ui_no_prompts_title": "Vēl nav uzvedņu", "com_ui_no_read_access": "Jums nav atļaujas skatÄ«t atmiņas", "com_ui_no_results_found": "Nav atrastu rezultātu", "com_ui_no_terms_content": "Nav noteikumu un nosacÄ«jumu satura, ko parādÄ«t", @@ -1205,6 +1211,7 @@ "com_ui_prompts": "Uzvednes", "com_ui_prompts_allow_create": "Atļaut uzvedņu izveidi", "com_ui_prompts_allow_share": "Atļaut kopÄ«goÅ”anas uzvednes", + "com_ui_prompts_allow_share_public": "Atļaut kopÄ«got uzvednes publiski", "com_ui_prompts_allow_use": "Atļaut izmantot uzvednes", "com_ui_provider": "Pakalpojumu sniedzējs", "com_ui_quality": "Kvalitāte", @@ -1385,6 +1392,7 @@ "com_ui_upload_file_context": "AugÅ”upielādēt failu kā kontekstu", "com_ui_upload_file_search": "AugÅ”upielādēt vektorizētai meklēŔanai", "com_ui_upload_files": "AugÅ”upielādēt failus", + "com_ui_upload_icon": "AugÅ”upielādēt ikonas attēlu", "com_ui_upload_image": "AugÅ”upielādēt failu kā attēlu", "com_ui_upload_image_input": "AugÅ”upielādēt failu kā attēlu", "com_ui_upload_invalid": "NederÄ«gs augÅ”upielādējamais fails. Attēlam jābÅ«t tādam, kas nepārsniedz ierobežojumu.", From 2a50c372efa7d999eed355c3e7657742513a8e37 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 12 Jan 2026 23:02:08 -0500 Subject: [PATCH 017/282] =?UTF-8?q?=F0=9F=AA=99=20refactor:=20Collected=20?= =?UTF-8?q?Usage=20&=20Anthropic=20Prompt=20Caching=20(#11319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ refactor: Improve token calculation in AgentClient.recordCollectedUsage - Updated the token calculation logic to sum output tokens directly from all entries, addressing issues with negative values in parallel execution scenarios. - Added comments for clarity on the usage of input tokens and output tokens. - Introduced a new test file for comprehensive testing of the recordCollectedUsage function, covering various execution scenarios including sequential and parallel processing, cache token handling, and model fallback logic. * šŸ”§ refactor: Anthropic `promptCache` handling in LLM configuration * šŸ”§ test: Add comprehensive test for cache token handling in recordCollectedUsage - Introduced a new test case to validate the handling of cache tokens across multiple tool calls in the recordCollectedUsage function. - Ensured correct calculations for input and output tokens, including scenarios with cache creation and reading. - Verified the expected interactions with token spending methods to enhance the robustness of the token management logic. --- api/package.json | 2 +- api/server/controllers/agents/client.js | 27 +- .../agents/recordCollectedUsage.spec.js | 712 ++++++++++++++++++ package-lock.json | 17 +- packages/api/package.json | 2 +- .../api/src/endpoints/anthropic/llm.spec.ts | 88 ++- packages/api/src/endpoints/anthropic/llm.ts | 6 + .../endpoints/openai/config.anthropic.spec.ts | 14 +- 8 files changed, 828 insertions(+), 40 deletions(-) create mode 100644 api/server/controllers/agents/recordCollectedUsage.spec.js diff --git a/api/package.json b/api/package.json index 9e134bd32a..c2f0dd9801 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.66", + "@librechat/agents": "^3.0.77", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 79e63d1c7f..2b5872411b 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -784,6 +784,7 @@ class AgentClient extends BaseClient { if (!collectedUsage || !collectedUsage.length) { return; } + // Use first entry's input_tokens as the base input (represents initial user message context) // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) const firstUsage = collectedUsage[0]; const input_tokens = @@ -795,10 +796,11 @@ class AgentClient extends BaseClient { Number(firstUsage?.cache_read_input_tokens) || 0); - let output_tokens = 0; - let previousTokens = input_tokens; // Start with original input - for (let i = 0; i < collectedUsage.length; i++) { - const usage = collectedUsage[i]; + // Sum output_tokens directly from all entries - works for both sequential and parallel execution + // This avoids the incremental calculation that produced negative values for parallel agents + let total_output_tokens = 0; + + for (const usage of collectedUsage) { if (!usage) { continue; } @@ -811,6 +813,9 @@ class AgentClient extends BaseClient { const cache_read = Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0; + // Accumulate output tokens for the usage summary + total_output_tokens += Number(usage.output_tokens) || 0; + const txMetadata = { context, balance, @@ -821,18 +826,6 @@ class AgentClient extends BaseClient { model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model, }; - if (i > 0) { - // Count new tokens generated (input_tokens minus previous accumulated tokens) - output_tokens += - (Number(usage.input_tokens) || 0) + cache_creation + cache_read - previousTokens; - } - - // Add this message's output tokens - output_tokens += Number(usage.output_tokens) || 0; - - // Update previousTokens to include this message's output - previousTokens += Number(usage.output_tokens) || 0; - if (cache_creation > 0 || cache_read > 0) { spendStructuredTokens(txMetadata, { promptTokens: { @@ -862,7 +855,7 @@ class AgentClient extends BaseClient { this.usage = { input_tokens, - output_tokens, + output_tokens: total_output_tokens, }; } diff --git a/api/server/controllers/agents/recordCollectedUsage.spec.js b/api/server/controllers/agents/recordCollectedUsage.spec.js new file mode 100644 index 0000000000..6904f2ed39 --- /dev/null +++ b/api/server/controllers/agents/recordCollectedUsage.spec.js @@ -0,0 +1,712 @@ +/** + * Tests for AgentClient.recordCollectedUsage + * + * This is a critical function that handles token spending for agent LLM calls. + * It must correctly handle: + * - Sequential execution (single agent with tool calls) + * - Parallel execution (multiple agents with independent inputs) + * - Cache token handling (OpenAI and Anthropic formats) + */ + +const { EModelEndpoint } = require('librechat-data-provider'); + +// Mock dependencies before requiring the module +const mockSpendTokens = jest.fn().mockResolvedValue(); +const mockSpendStructuredTokens = jest.fn().mockResolvedValue(); + +jest.mock('~/models/spendTokens', () => ({ + spendTokens: (...args) => mockSpendTokens(...args), + spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), +})); + +jest.mock('~/config', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, + getMCPManager: jest.fn(() => ({ + formatInstructionsForContext: jest.fn(), + })), +})); + +jest.mock('@librechat/agents', () => ({ + ...jest.requireActual('@librechat/agents'), + createMetadataAggregator: () => ({ + handleLLMEnd: jest.fn(), + collected: [], + }), +})); + +const AgentClient = require('./client'); + +describe('AgentClient - recordCollectedUsage', () => { + let client; + let mockAgent; + let mockOptions; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAgent = { + id: 'agent-123', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { + model: 'gpt-4', + }, + }; + + mockOptions = { + req: { + user: { id: 'user-123' }, + body: { model: 'gpt-4', endpoint: EModelEndpoint.openAI }, + }, + res: {}, + agent: mockAgent, + endpointTokenConfig: {}, + }; + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.user = 'user-123'; + }); + + describe('basic functionality', () => { + it('should return early if collectedUsage is empty', async () => { + await client.recordCollectedUsage({ + collectedUsage: [], + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(client.usage).toBeUndefined(); + }); + + it('should return early if collectedUsage is null', async () => { + await client.recordCollectedUsage({ + collectedUsage: null, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(client.usage).toBeUndefined(); + }); + + it('should handle single usage entry correctly', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'convo-123', + user: 'user-123', + model: 'gpt-4', + }), + { promptTokens: 100, completionTokens: 50 }, + ); + expect(client.usage.input_tokens).toBe(100); + expect(client.usage.output_tokens).toBe(50); + }); + + it('should skip null entries in collectedUsage', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + null, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + }); + }); + + describe('sequential execution (single agent with tool calls)', () => { + it('should calculate tokens correctly for sequential tool calls', async () => { + // Sequential flow: output of call N becomes part of input for call N+1 + // Call 1: input=100, output=50 + // Call 2: input=150 (100+50), output=30 + // Call 3: input=180 (150+30), output=20 + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 150, output_tokens: 30, model: 'gpt-4' }, + { input_tokens: 180, output_tokens: 20, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(3); + // Total output should be sum of all output_tokens: 50 + 30 + 20 = 100 + expect(client.usage.output_tokens).toBe(100); + expect(client.usage.input_tokens).toBe(100); // First entry's input + }); + }); + + describe('parallel execution (multiple agents)', () => { + it('should handle parallel agents with independent input tokens', async () => { + // Parallel agents have INDEPENDENT input tokens (not cumulative) + // Agent A: input=100, output=50 + // Agent B: input=80, output=40 (different context, not 100+50) + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + // Expected total output: 50 + 40 = 90 + // output_tokens must be positive and should reflect total output + expect(client.usage.output_tokens).toBeGreaterThan(0); + }); + + it('should NOT produce negative output_tokens for parallel execution', async () => { + // Critical bug scenario: parallel agents where second agent has LOWER input tokens + const collectedUsage = [ + { input_tokens: 200, output_tokens: 100, model: 'gpt-4' }, + { input_tokens: 50, output_tokens: 30, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + // output_tokens MUST be positive for proper token tracking + expect(client.usage.output_tokens).toBeGreaterThan(0); + // Correct value should be 100 + 30 = 130 + }); + + it('should calculate correct total output for parallel agents', async () => { + // Three parallel agents with independent contexts + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 120, output_tokens: 60, model: 'gpt-4-turbo' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(3); + // Total output should be 50 + 60 + 40 = 150 + expect(client.usage.output_tokens).toBe(150); + }); + + it('should handle worst-case parallel scenario without negative tokens', async () => { + // Extreme case: first agent has very high input, subsequent have low + const collectedUsage = [ + { input_tokens: 1000, output_tokens: 500, model: 'gpt-4' }, + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 50, output_tokens: 25, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + // Must be positive, should be 500 + 50 + 25 = 575 + expect(client.usage.output_tokens).toBeGreaterThan(0); + expect(client.usage.output_tokens).toBe(575); + }); + }); + + describe('real-world scenarios', () => { + it('should correctly sum output tokens for sequential tool calls with growing context', async () => { + // Real production data: Claude Opus with multiple tool calls + // Context grows as tool results are added, but output_tokens should only count model generations + const collectedUsage = [ + { + input_tokens: 31596, + output_tokens: 151, + total_tokens: 31747, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 35368, + output_tokens: 150, + total_tokens: 35518, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 58362, + output_tokens: 295, + total_tokens: 58657, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 112604, + output_tokens: 193, + total_tokens: 112797, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 257440, + output_tokens: 2217, + total_tokens: 259657, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + // input_tokens should be first entry's input (initial context) + expect(client.usage.input_tokens).toBe(31596); + + // output_tokens should be sum of all model outputs: 151 + 150 + 295 + 193 + 2217 = 3006 + // NOT the inflated value from incremental calculation (338,559) + expect(client.usage.output_tokens).toBe(3006); + + // Verify spendTokens was called for each entry with correct values + expect(mockSpendTokens).toHaveBeenCalledTimes(5); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), + { promptTokens: 31596, completionTokens: 151 }, + ); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 5, + expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), + { promptTokens: 257440, completionTokens: 2217 }, + ); + }); + + it('should handle single followup message correctly', async () => { + // Real production data: followup to the above conversation + const collectedUsage = [ + { + input_tokens: 263406, + output_tokens: 257, + total_tokens: 263663, + input_token_details: { cache_read: 0, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(client.usage.input_tokens).toBe(263406); + expect(client.usage.output_tokens).toBe(257); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), + { promptTokens: 263406, completionTokens: 257 }, + ); + }); + + it('should ensure output_tokens > 0 check passes for BaseClient.sendMessage', async () => { + // This verifies the fix for the duplicate token spending bug + // BaseClient.sendMessage checks: if (usage != null && Number(usage[this.outputTokensKey]) > 0) + const collectedUsage = [ + { + input_tokens: 31596, + output_tokens: 151, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 35368, + output_tokens: 150, + model: 'claude-opus-4-5-20251101', + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + const usage = client.getStreamUsage(); + + // The check that was failing before the fix + expect(usage).not.toBeNull(); + expect(Number(usage.output_tokens)).toBeGreaterThan(0); + + // Verify correct value + expect(usage.output_tokens).toBe(301); // 151 + 150 + }); + + it('should correctly handle cache tokens with multiple tool calls', async () => { + // Real production data: Claude Opus with cache tokens (prompt caching) + // First entry has cache_creation, subsequent entries have cache_read + const collectedUsage = [ + { + input_tokens: 788, + output_tokens: 163, + total_tokens: 951, + input_token_details: { cache_read: 0, cache_creation: 30808 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 3802, + output_tokens: 149, + total_tokens: 3951, + input_token_details: { cache_read: 30808, cache_creation: 768 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 26808, + output_tokens: 225, + total_tokens: 27033, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 80912, + output_tokens: 204, + total_tokens: 81116, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 136454, + output_tokens: 206, + total_tokens: 136660, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 146316, + output_tokens: 224, + total_tokens: 146540, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 150402, + output_tokens: 1248, + total_tokens: 151650, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 156268, + output_tokens: 139, + total_tokens: 156407, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + { + input_tokens: 167126, + output_tokens: 2961, + total_tokens: 170087, + input_token_details: { cache_read: 31576, cache_creation: 0 }, + model: 'claude-opus-4-5-20251101', + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + // input_tokens = first entry's input + cache_creation + cache_read + // = 788 + 30808 + 0 = 31596 + expect(client.usage.input_tokens).toBe(31596); + + // output_tokens = sum of all output_tokens + // = 163 + 149 + 225 + 204 + 206 + 224 + 1248 + 139 + 2961 = 5519 + expect(client.usage.output_tokens).toBe(5519); + + // First 2 entries have cache tokens, should use spendStructuredTokens + // Remaining 7 entries have cache_read but no cache_creation, still structured + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(9); + expect(mockSpendTokens).toHaveBeenCalledTimes(0); + + // Verify first entry uses structured tokens with cache_creation + expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), + { + promptTokens: { input: 788, write: 30808, read: 0 }, + completionTokens: 163, + }, + ); + + // Verify second entry uses structured tokens with both cache_creation and cache_read + expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), + { + promptTokens: { input: 3802, write: 768, read: 30808 }, + completionTokens: 149, + }, + ); + }); + }); + + describe('cache token handling', () => { + it('should handle OpenAI format cache tokens (input_token_details)', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { + cache_creation: 20, + cache_read: 10, + }, + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4' }), + { + promptTokens: { + input: 100, + write: 20, + read: 10, + }, + completionTokens: 50, + }, + ); + }); + + it('should handle Anthropic format cache tokens (cache_*_input_tokens)', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'claude-3' }), + { + promptTokens: { + input: 100, + write: 25, + read: 15, + }, + completionTokens: 50, + }, + ); + }); + + it('should use spendTokens for entries without cache tokens', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + + it('should handle mixed cache and non-cache entries', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'gpt-4', + input_token_details: { cache_creation: 10, cache_read: 5 }, + }, + { input_tokens: 200, output_tokens: 20, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + }); + + it('should include cache tokens in total input calculation', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { + cache_creation: 20, + cache_read: 10, + }, + }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + // Total input should include cache tokens: 100 + 20 + 10 = 130 + expect(client.usage.input_tokens).toBe(130); + }); + }); + + describe('model fallback', () => { + it('should use usage.model when available', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4-turbo' }]; + + await client.recordCollectedUsage({ + model: 'fallback-model', + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4-turbo' }), + expect.any(Object), + ); + }); + + it('should fallback to param model when usage.model is missing', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; + + await client.recordCollectedUsage({ + model: 'param-model', + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'param-model' }), + expect.any(Object), + ); + }); + + it('should fallback to client.model when param model is missing', async () => { + client.model = 'client-model'; + const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'client-model' }), + expect.any(Object), + ); + }); + + it('should fallback to agent model_parameters.model as last resort', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4' }), + expect.any(Object), + ); + }); + }); + + describe('getStreamUsage integration', () => { + it('should return the usage object set by recordCollectedUsage', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + const usage = client.getStreamUsage(); + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + }); + }); + + it('should return undefined before recordCollectedUsage is called', () => { + const usage = client.getStreamUsage(); + expect(usage).toBeUndefined(); + }); + + it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => { + // This test verifies the usage will pass the check in BaseClient.sendMessage: + // if (usage != null && Number(usage[this.outputTokensKey]) > 0) + const collectedUsage = [ + { input_tokens: 200, output_tokens: 100, model: 'gpt-4' }, + { input_tokens: 50, output_tokens: 30, model: 'gpt-4' }, + ]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + const usage = client.getStreamUsage(); + expect(usage).not.toBeNull(); + expect(Number(usage.output_tokens)).toBeGreaterThan(0); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3337696267..6456b21325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.66", + "@librechat/agents": "^3.0.77", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12660,9 +12660,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.66", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.66.tgz", - "integrity": "sha512-JpQo7w+/yLM3dJ46lyGrm4gPTjiHERwcpojw7drvpYWqOU4e2jmjK0JbNxQ0jP00q+nDhPG+mqJ2qQU7TVraOQ==", + "version": "3.0.77", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.77.tgz", + "integrity": "sha512-Wr9d8bjJAQSl03nEgnAPG6jBQT1fL3sNV3TFDN1FvFQt6WGfdok838Cbcn+/tSGXSPJcICTxNkMT7VN8P6bCPw==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -12686,6 +12686,7 @@ "https-proxy-agent": "^7.0.6", "mathjs": "^15.1.0", "nanoid": "^3.3.7", + "okapibm25": "^1.4.1", "openai": "5.8.2" }, "engines": { @@ -34310,6 +34311,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/okapibm25": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/okapibm25/-/okapibm25-1.4.1.tgz", + "integrity": "sha512-UHmeH4MAtZXGFVncwbY7pfFvDVNxpsyM3W66aGPU0SHj1+ld59ty+9lJ0ifcrcnPUl1XdYoDgb06ObyCnpTs3g==", + "license": "MIT" + }, "node_modules/ollama": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.18.tgz", @@ -43169,7 +43176,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.66", + "@librechat/agents": "^3.0.77", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index 8538264ceb..5f5576e293 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -88,7 +88,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.66", + "@librechat/agents": "^3.0.77", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/endpoints/anthropic/llm.spec.ts b/packages/api/src/endpoints/anthropic/llm.spec.ts index 0e457b60c2..c15d5445ed 100644 --- a/packages/api/src/endpoints/anthropic/llm.spec.ts +++ b/packages/api/src/endpoints/anthropic/llm.spec.ts @@ -87,7 +87,7 @@ describe('getLLMConfig', () => { expect(result.llmConfig.thinking).toHaveProperty('budget_tokens', 2000); }); - it('should add "context-1m" beta header for claude-sonnet-4 model', () => { + it('should add "context-1m" beta header and promptCache boolean for claude-sonnet-4 model', () => { const modelOptions = { model: 'claude-sonnet-4-20250514', promptCache: true, @@ -98,9 +98,10 @@ describe('getLLMConfig', () => { expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); const defaultHeaders = clientOptions?.defaultHeaders as Record; expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); + expect(result.llmConfig.promptCache).toBe(true); }); - it('should add "context-1m" beta header for claude-sonnet-4 model formats', () => { + it('should add "context-1m" beta header and promptCache boolean for claude-sonnet-4 model formats', () => { const modelVariations = [ 'claude-sonnet-4-20250514', 'claude-sonnet-4-latest', @@ -115,10 +116,11 @@ describe('getLLMConfig', () => { expect(clientOptions?.defaultHeaders).toHaveProperty('anthropic-beta'); const defaultHeaders = clientOptions?.defaultHeaders as Record; expect(defaultHeaders['anthropic-beta']).toBe('context-1m-2025-08-07'); + expect(result.llmConfig.promptCache).toBe(true); }); }); - it('should not add beta headers for claude-opus-4-5 model (prompt caching no longer needs header)', () => { + it('should pass promptCache boolean for claude-opus-4-5 model (no beta header needed)', () => { const modelOptions = { model: 'claude-opus-4-5', promptCache: true, @@ -126,9 +128,10 @@ describe('getLLMConfig', () => { const result = getLLMConfig('test-key', { modelOptions }); const clientOptions = result.llmConfig.clientOptions; expect(clientOptions?.defaultHeaders).toBeUndefined(); + expect(result.llmConfig.promptCache).toBe(true); }); - it('should not add beta headers for claude-opus-4-5 model formats (prompt caching no longer needs header)', () => { + it('should pass promptCache boolean for claude-opus-4-5 model formats (no beta header needed)', () => { const modelVariations = [ 'claude-opus-4-5', 'claude-opus-4-5-20250420', @@ -141,6 +144,7 @@ describe('getLLMConfig', () => { const result = getLLMConfig('test-key', { modelOptions }); const clientOptions = result.llmConfig.clientOptions; expect(clientOptions?.defaultHeaders).toBeUndefined(); + expect(result.llmConfig.promptCache).toBe(true); }); }); @@ -299,10 +303,11 @@ describe('getLLMConfig', () => { }, }); - // claude-3-5-sonnet supports prompt caching and should get the max-tokens header + // claude-3-5-sonnet supports prompt caching and should get the max-tokens header and promptCache boolean expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({ 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', }); + expect(result.llmConfig.promptCache).toBe(true); }); it('should handle thinking and thinkingBudget options', () => { @@ -512,6 +517,8 @@ describe('getLLMConfig', () => { expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({ 'anthropic-beta': 'token-efficient-tools-2025-02-19,output-128k-2025-02-19', }); + // Should pass promptCache boolean + expect(result.llmConfig.promptCache).toBe(true); }); it('should handle web search functionality like production', () => { @@ -1160,21 +1167,66 @@ describe('getLLMConfig', () => { it('should handle prompt cache support logic for different models', () => { const testCases = [ // Models that support prompt cache (and have other beta headers) - { model: 'claude-3-5-sonnet', promptCache: true, shouldHaveHeaders: true }, - { model: 'claude-3.5-sonnet-20241022', promptCache: true, shouldHaveHeaders: true }, - { model: 'claude-3-7-sonnet', promptCache: true, shouldHaveHeaders: true }, - { model: 'claude-3.7-sonnet-20250109', promptCache: true, shouldHaveHeaders: true }, - { model: 'claude-sonnet-4-20250514', promptCache: true, shouldHaveHeaders: true }, + { + model: 'claude-3-5-sonnet', + promptCache: true, + shouldHaveHeaders: true, + shouldHavePromptCache: true, + }, + { + model: 'claude-3.5-sonnet-20241022', + promptCache: true, + shouldHaveHeaders: true, + shouldHavePromptCache: true, + }, + { + model: 'claude-3-7-sonnet', + promptCache: true, + shouldHaveHeaders: true, + shouldHavePromptCache: true, + }, + { + model: 'claude-3.7-sonnet-20250109', + promptCache: true, + shouldHaveHeaders: true, + shouldHavePromptCache: true, + }, + { + model: 'claude-sonnet-4-20250514', + promptCache: true, + shouldHaveHeaders: true, + shouldHavePromptCache: true, + }, // Models that support prompt cache but have no additional beta headers needed - { model: 'claude-3-opus', promptCache: true, shouldHaveHeaders: false }, + { + model: 'claude-3-opus', + promptCache: true, + shouldHaveHeaders: false, + shouldHavePromptCache: true, + }, // Models that don't support prompt cache - { model: 'claude-3-5-sonnet-latest', promptCache: true, shouldHaveHeaders: false }, - { model: 'claude-3.5-sonnet-latest', promptCache: true, shouldHaveHeaders: false }, + { + model: 'claude-3-5-sonnet-latest', + promptCache: true, + shouldHaveHeaders: false, + shouldHavePromptCache: false, + }, + { + model: 'claude-3.5-sonnet-latest', + promptCache: true, + shouldHaveHeaders: false, + shouldHavePromptCache: false, + }, // Prompt cache disabled - { model: 'claude-3-5-sonnet', promptCache: false, shouldHaveHeaders: false }, + { + model: 'claude-3-5-sonnet', + promptCache: false, + shouldHaveHeaders: false, + shouldHavePromptCache: false, + }, ]; - testCases.forEach(({ model, promptCache, shouldHaveHeaders }) => { + testCases.forEach(({ model, promptCache, shouldHaveHeaders, shouldHavePromptCache }) => { const result = getLLMConfig('test-key', { modelOptions: { model, promptCache }, }); @@ -1187,6 +1239,12 @@ describe('getLLMConfig', () => { } else { expect(headers).toBeUndefined(); } + + if (shouldHavePromptCache) { + expect(result.llmConfig.promptCache).toBe(true); + } else { + expect(result.llmConfig.promptCache).toBeUndefined(); + } }); }); }); diff --git a/packages/api/src/endpoints/anthropic/llm.ts b/packages/api/src/endpoints/anthropic/llm.ts index 408ad2a77c..34ec354365 100644 --- a/packages/api/src/endpoints/anthropic/llm.ts +++ b/packages/api/src/endpoints/anthropic/llm.ts @@ -155,6 +155,12 @@ function getLLMConfig( const supportsCacheControl = systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model ?? ''); + + /** Pass promptCache boolean for downstream cache_control application */ + if (supportsCacheControl) { + (requestOptions as Record).promptCache = true; + } + const headers = getClaudeHeaders(requestOptions.model ?? '', supportsCacheControl); if (headers && requestOptions.clientOptions) { requestOptions.clientOptions.defaultHeaders = headers; diff --git a/packages/api/src/endpoints/openai/config.anthropic.spec.ts b/packages/api/src/endpoints/openai/config.anthropic.spec.ts index eeb17a311d..7109341e8c 100644 --- a/packages/api/src/endpoints/openai/config.anthropic.spec.ts +++ b/packages/api/src/endpoints/openai/config.anthropic.spec.ts @@ -39,6 +39,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { type: 'enabled', budget_tokens: 2000, }, + promptCache: true, }, }, configOptions: { @@ -87,6 +88,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { type: 'enabled', budget_tokens: 3000, }, + promptCache: true, }, }, configOptions: { @@ -134,6 +136,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { user_id: 'user123', }, topK: 50, + promptCache: true, }, }, configOptions: { @@ -175,6 +178,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { metadata: { user_id: 'user456', }, + promptCache: true, }, }, configOptions: { @@ -187,7 +191,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { }); }); - it('should apply custom headers without anthropic-beta for models that dont need it', () => { + it('should apply custom headers and promptCache for models that support caching', () => { const apiKey = 'sk-custom'; const endpoint = 'Anthropic (via LiteLLM)'; const options = { @@ -218,6 +222,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { metadata: { user_id: undefined, }, + promptCache: true, }, }, configOptions: { @@ -300,6 +305,9 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { stream: true, topP: 0.9, maxTokens: 2048, + modelKwargs: { + promptCache: true, + }, // temperature is dropped // modelKwargs.topK is dropped // modelKwargs.metadata is dropped completely @@ -379,6 +387,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { metadata: { user_id: 'searchUser', }, + promptCache: true, }, }, configOptions: { @@ -425,6 +434,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { user_id: 'testUser', }, topK: 40, + promptCache: true, }, }, configOptions: { @@ -470,6 +480,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { metadata: { user_id: 'addUser', }, + promptCache: true, customParam1: 'value1', // Unknown params added to modelKwargs customParam2: 42, }, @@ -519,6 +530,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => { metadata: { user_id: 'bothUser', }, + promptCache: true, customParam: 'customValue', // topK is dropped }, From 5617bf71bea42fa87e8ed9b7b5749545f3ae47a4 Mon Sep 17 00:00:00 2001 From: Artyom Bogachenko <32168471+SpectralOne@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:53:14 +0300 Subject: [PATCH 018/282] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Correct=20Subpath?= =?UTF-8?q?=20Routing=20for=20SSE=20and=20Favorites=20Endpoints=20(#11339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Artyom Bogachenco --- client/src/data-provider/SSE/mutations.ts | 7 +++++-- client/src/data-provider/SSE/queries.ts | 6 ++++-- client/src/hooks/SSE/useResumableSSE.ts | 3 ++- packages/data-provider/src/data-service.ts | 4 ++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/src/data-provider/SSE/mutations.ts b/client/src/data-provider/SSE/mutations.ts index f24fed1b07..0861babbe9 100644 --- a/client/src/data-provider/SSE/mutations.ts +++ b/client/src/data-provider/SSE/mutations.ts @@ -1,5 +1,5 @@ import { useMutation } from '@tanstack/react-query'; -import { request } from 'librechat-data-provider'; +import { apiBaseUrl, request } from 'librechat-data-provider'; export interface AbortStreamParams { /** The stream ID to abort (if known) */ @@ -23,7 +23,10 @@ export interface AbortStreamResponse { */ export const abortStream = async (params: AbortStreamParams): Promise => { console.log('[abortStream] Calling abort endpoint with params:', params); - const result = (await request.post('/api/agents/chat/abort', params)) as AbortStreamResponse; + const result = (await request.post( + `${apiBaseUrl()}/api/agents/chat/abort`, + params, + )) as AbortStreamResponse; console.log('[abortStream] Abort response:', result); return result; }; diff --git a/client/src/data-provider/SSE/queries.ts b/client/src/data-provider/SSE/queries.ts index ec937fe878..76c500c530 100644 --- a/client/src/data-provider/SSE/queries.ts +++ b/client/src/data-provider/SSE/queries.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { QueryKeys, request, dataService } from 'librechat-data-provider'; +import { apiBaseUrl, QueryKeys, request, dataService } from 'librechat-data-provider'; import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query'; import type { Agents, TConversation } from 'librechat-data-provider'; import { updateConvoInAllQueries } from '~/utils'; @@ -16,7 +16,9 @@ export interface StreamStatusResponse { export const streamStatusQueryKey = (conversationId: string) => ['streamStatus', conversationId]; export const fetchStreamStatus = async (conversationId: string): Promise => { - return request.get(`/api/agents/chat/status/${conversationId}`); + return request.get( + `${apiBaseUrl()}/api/agents/chat/status/${conversationId}`, + ); }; export function useStreamStatus(conversationId: string | undefined, enabled = true) { diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 1bfb2706d5..ee04bcf32f 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -4,6 +4,7 @@ import { SSE } from 'sse.js'; import { useSetRecoilState } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; import { + apiBaseUrl, request, Constants, QueryKeys, @@ -144,7 +145,7 @@ export default function useResumableSSE( let { userMessage } = currentSubmission; let textIndex: number | null = null; - const baseUrl = `/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}`; + const baseUrl = `${apiBaseUrl()}/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}`; const url = isResume ? `${baseUrl}?resume=true` : baseUrl; console.log('[ResumableSSE] Subscribing to stream:', url, { isResume }); diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 1c8199ce7a..911cc7863c 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -32,11 +32,11 @@ export type FavoriteItem = { }; export function getFavorites(): Promise { - return request.get('/api/user/settings/favorites'); + return request.get(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`); } export function updateFavorites(favorites: FavoriteItem[]): Promise { - return request.post('/api/user/settings/favorites', { favorites }); + return request.post(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`, { favorites }); } export function getSharedMessages(shareId: string): Promise { From 774f1f2cc28d4279c702f4022853841fa5391391 Mon Sep 17 00:00:00 2001 From: heptapod <164861708+leondape@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:44:57 +0100 Subject: [PATCH 019/282] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore:=20Remove?= =?UTF-8?q?=20YouTube=20API=20integration=20(#11331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ—‘ļø refactor: Remove YouTube API integration and related configurations as it's broken and should be integrated via MCP instead. Currently there seems not to be a single MCP out there with working get_transcript methods for months. API seems to have changed and there are no maintainers on these projects. We will work out something soon an MCP solution - Deleted YouTube API key and related configurations from .env.example. - Removed YouTube tools and their references from the API client, including the manifest and structured files. - Updated package.json to remove YouTube-related dependencies. - Cleaned up toolkit exports by removing YouTube toolkit references. * chore: revert package removal to properly remove packages * šŸ—‘ļø refactor: Remove YouTube API and related dependencies due to integration issues --------- Co-authored-by: Danny Avila --- .env.example | 4 - api/app/clients/tools/index.js | 2 - api/app/clients/tools/manifest.json | 14 -- api/app/clients/tools/structured/YouTube.js | 137 -------------------- api/app/clients/tools/util/handleTools.js | 6 - api/package.json | 2 - package-lock.json | 44 ------- packages/api/src/tools/toolkits/index.ts | 1 - packages/api/src/tools/toolkits/yt.ts | 61 --------- 9 files changed, 271 deletions(-) delete mode 100644 api/app/clients/tools/structured/YouTube.js delete mode 100644 packages/api/src/tools/toolkits/yt.ts diff --git a/.env.example b/.env.example index b5613fdfca..9864a41482 100644 --- a/.env.example +++ b/.env.example @@ -331,10 +331,6 @@ FLUX_API_BASE_URL=https://api.us1.bfl.ai GOOGLE_SEARCH_API_KEY= GOOGLE_CSE_ID= -# YOUTUBE -#----------------- -YOUTUBE_API_KEY= - # Stable Diffusion #----------------- SD_WEBUI_URL=http://host.docker.internal:7860 diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index 1a7c4ff47f..bb58e81221 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -5,7 +5,6 @@ const DALLE3 = require('./structured/DALLE3'); const FluxAPI = require('./structured/FluxAPI'); const OpenWeather = require('./structured/OpenWeather'); const StructuredWolfram = require('./structured/Wolfram'); -const createYouTubeTools = require('./structured/YouTube'); const StructuredACS = require('./structured/AzureAISearch'); const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); @@ -25,7 +24,6 @@ module.exports = { GoogleSearchAPI, TraversaalSearch, StructuredWolfram, - createYouTubeTools, TavilySearchResults, createOpenAIImageTools, createGeminiImageTool, diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index fc037caa4b..9262113501 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -30,20 +30,6 @@ } ] }, - { - "name": "YouTube", - "pluginKey": "youtube", - "toolkit": true, - "description": "Get YouTube video information, retrieve comments, analyze transcripts and search for videos.", - "icon": "https://www.youtube.com/s/desktop/7449ebf7/img/favicon_144x144.png", - "authConfig": [ - { - "authField": "YOUTUBE_API_KEY", - "label": "YouTube API Key", - "description": "Your YouTube Data API v3 key." - } - ] - }, { "name": "OpenAI Image Tools", "pluginKey": "image_gen_oai", diff --git a/api/app/clients/tools/structured/YouTube.js b/api/app/clients/tools/structured/YouTube.js deleted file mode 100644 index 8d1c7b9ff9..0000000000 --- a/api/app/clients/tools/structured/YouTube.js +++ /dev/null @@ -1,137 +0,0 @@ -const { ytToolkit } = require('@librechat/api'); -const { tool } = require('@langchain/core/tools'); -const { youtube } = require('@googleapis/youtube'); -const { logger } = require('@librechat/data-schemas'); -const { YoutubeTranscript } = require('youtube-transcript'); -const { getApiKey } = require('./credentials'); - -function extractVideoId(url) { - const rawIdRegex = /^[a-zA-Z0-9_-]{11}$/; - if (rawIdRegex.test(url)) { - return url; - } - - const regex = new RegExp( - '(?:youtu\\.be/|youtube(?:\\.com)?/(?:' + - '(?:watch\\?v=)|(?:embed/)|(?:shorts/)|(?:live/)|(?:v/)|(?:/))?)' + - '([a-zA-Z0-9_-]{11})(?:\\S+)?$', - ); - const match = url.match(regex); - return match ? match[1] : null; -} - -function parseTranscript(transcriptResponse) { - if (!Array.isArray(transcriptResponse)) { - return ''; - } - - return transcriptResponse - .map((entry) => entry.text.trim()) - .filter((text) => text) - .join(' ') - .replaceAll('&#39;', "'"); -} - -function createYouTubeTools(fields = {}) { - const envVar = 'YOUTUBE_API_KEY'; - const override = fields.override ?? false; - const apiKey = fields.apiKey ?? fields[envVar] ?? getApiKey(envVar, override); - - const youtubeClient = youtube({ - version: 'v3', - auth: apiKey, - }); - - const searchTool = tool(async ({ query, maxResults = 5 }) => { - const response = await youtubeClient.search.list({ - part: 'snippet', - q: query, - type: 'video', - maxResults: maxResults || 5, - }); - const result = response.data.items.map((item) => ({ - title: item.snippet.title, - description: item.snippet.description, - url: `https://www.youtube.com/watch?v=${item.id.videoId}`, - })); - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_search); - - const infoTool = tool(async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - const response = await youtubeClient.videos.list({ - part: 'snippet,statistics', - id: videoId, - }); - - if (!response.data.items?.length) { - throw new Error('Video not found'); - } - const video = response.data.items[0]; - - const result = { - title: video.snippet.title, - description: video.snippet.description, - views: video.statistics.viewCount, - likes: video.statistics.likeCount, - comments: video.statistics.commentCount, - }; - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_info); - - const commentsTool = tool(async ({ url, maxResults = 10 }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - const response = await youtubeClient.commentThreads.list({ - part: 'snippet', - videoId, - maxResults: maxResults || 10, - }); - - const result = response.data.items.map((item) => ({ - author: item.snippet.topLevelComment.snippet.authorDisplayName, - text: item.snippet.topLevelComment.snippet.textDisplay, - likes: item.snippet.topLevelComment.snippet.likeCount, - })); - return JSON.stringify(result, null, 2); - }, ytToolkit.youtube_comments); - - const transcriptTool = tool(async ({ url }) => { - const videoId = extractVideoId(url); - if (!videoId) { - throw new Error('Invalid YouTube URL or video ID'); - } - - try { - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'en' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - try { - const transcript = await YoutubeTranscript.fetchTranscript(videoId, { lang: 'de' }); - return parseTranscript(transcript); - } catch (e) { - logger.error(e); - } - - const transcript = await YoutubeTranscript.fetchTranscript(videoId); - return parseTranscript(transcript); - } catch (error) { - throw new Error(`Failed to fetch transcript: ${error.message}`); - } - }, ytToolkit.youtube_transcript); - - return [searchTool, infoTool, commentsTool, transcriptTool]; -} - -module.exports = createYouTubeTools; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index e39bebd36a..da4c687b4d 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -34,7 +34,6 @@ const { StructuredACS, TraversaalSearch, StructuredWolfram, - createYouTubeTools, TavilySearchResults, createGeminiImageTool, createOpenAIImageTools, @@ -185,11 +184,6 @@ const loadTools = async ({ }; const customConstructors = { - youtube: async (_toolContextMap) => { - const authFields = getAuthFields('youtube'); - const authValues = await loadAuthValues({ userId: user, authFields }); - return createYouTubeTools(authValues); - }, image_gen_oai: async (toolContextMap) => { const authFields = getAuthFields('image_gen_oai'); const authValues = await loadAuthValues({ userId: user, authFields }); diff --git a/api/package.json b/api/package.json index c2f0dd9801..0881070652 100644 --- a/api/package.json +++ b/api/package.json @@ -43,7 +43,6 @@ "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.27.0", "@google/genai": "^1.19.0", - "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", "@librechat/agents": "^3.0.77", @@ -112,7 +111,6 @@ "undici": "^7.10.0", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", - "youtube-transcript": "^1.2.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 6456b21325..18e55217d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,6 @@ "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.27.0", "@google/genai": "^1.19.0", - "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", "@librechat/agents": "^3.0.77", @@ -126,7 +125,6 @@ "undici": "^7.10.0", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", - "youtube-transcript": "^1.2.1", "zod": "^3.22.4" }, "devDependencies": { @@ -10739,18 +10737,6 @@ "node": ">=18.0.0" } }, - "node_modules/@googleapis/youtube": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-20.0.0.tgz", - "integrity": "sha512-wdt1J0JoKYhvpoS2XIRHX0g/9ul/B0fQeeJAhuuBIdYINuuLt6/oZYZZCBmkuhtkA3IllXgqgAXOjLtLRAnR2g==", - "license": "Apache-2.0", - "dependencies": { - "googleapis-common": "^7.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@grpc/grpc-js": { "version": "1.9.15", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", @@ -27690,22 +27676,6 @@ "node": ">=14" } }, - "node_modules/googleapis-common": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.0.1.tgz", - "integrity": "sha512-mgt5zsd7zj5t5QXvDanjWguMdHAcJmmDrF9RkInCecNsyV7S7YtGqm5v2IWONNID88osb7zmx5FtrAP12JfD0w==", - "dependencies": { - "extend": "^3.0.2", - "gaxios": "^6.0.3", - "google-auth-library": "^9.0.0", - "qs": "^6.7.0", - "url-template": "^2.0.8", - "uuid": "^9.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -41538,11 +41508,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" - }, "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -43095,15 +43060,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/youtube-transcript": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.2.1.tgz", - "integrity": "sha512-TvEGkBaajKw+B6y91ziLuBLsa5cawgowou+Bk0ciGpjELDfAzSzTGXaZmeSSkUeknCPpEr/WGApOHDwV7V+Y9Q==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/zod": { "version": "3.25.67", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", diff --git a/packages/api/src/tools/toolkits/index.ts b/packages/api/src/tools/toolkits/index.ts index def468d18b..ce9e0584c4 100644 --- a/packages/api/src/tools/toolkits/index.ts +++ b/packages/api/src/tools/toolkits/index.ts @@ -1,4 +1,3 @@ export * from './gemini'; export * from './imageContext'; export * from './oai'; -export * from './yt'; diff --git a/packages/api/src/tools/toolkits/yt.ts b/packages/api/src/tools/toolkits/yt.ts deleted file mode 100644 index 7185a260d7..0000000000 --- a/packages/api/src/tools/toolkits/yt.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from 'zod'; -export const ytToolkit = { - youtube_search: { - name: 'youtube_search' as const, - description: `Search for YouTube videos by keyword or phrase. -- Required: query (search terms to find videos) -- Optional: maxResults (number of videos to return, 1-50, default: 5) -- Returns: List of videos with titles, descriptions, and URLs -- Use for: Finding specific videos, exploring content, research -Example: query="cooking pasta tutorials" maxResults=3` as const, - schema: z.object({ - query: z.string().describe('Search query terms'), - maxResults: z.number().int().min(1).max(50).optional().describe('Number of results (1-50)'), - }), - }, - youtube_info: { - name: 'youtube_info' as const, - description: `Get detailed metadata and statistics for a specific YouTube video. -- Required: url (full YouTube URL or video ID) -- Returns: Video title, description, view count, like count, comment count -- Use for: Getting video metrics and basic metadata -- DO NOT USE FOR VIDEO SUMMARIES, USE TRANSCRIPTS FOR COMPREHENSIVE ANALYSIS -- Accepts both full URLs and video IDs -Example: url="https://youtube.com/watch?v=abc123" or url="abc123"` as const, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - }), - } as const, - youtube_comments: { - name: 'youtube_comments', - description: `Retrieve top-level comments from a YouTube video. -- Required: url (full YouTube URL or video ID) -- Optional: maxResults (number of comments, 1-50, default: 10) -- Returns: Comment text, author names, like counts -- Use for: Sentiment analysis, audience feedback, engagement review -Example: url="abc123" maxResults=20`, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - maxResults: z - .number() - .int() - .min(1) - .max(50) - .optional() - .describe('Number of comments to retrieve'), - }), - } as const, - youtube_transcript: { - name: 'youtube_transcript', - description: `Fetch and parse the transcript/captions of a YouTube video. -- Required: url (full YouTube URL or video ID) -- Returns: Full video transcript as plain text -- Use for: Content analysis, summarization, translation reference -- This is the "Go-to" tool for analyzing actual video content -- Attempts to fetch English first, then German, then any available language -Example: url="https://youtube.com/watch?v=abc123"`, - schema: z.object({ - url: z.string().describe('YouTube video URL or ID'), - }), - } as const, -} as const; From 10f591ab1cced568227744d0b7056339944970ac Mon Sep 17 00:00:00 2001 From: Andrei Blizorukov <55080535+ablizorukov@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:49:02 +0100 Subject: [PATCH 020/282] =?UTF-8?q?=F0=9F=93=8A=20refactor:=20Use=20Estima?= =?UTF-8?q?ted=20Document=20Count=20for=20Meilisearch=20Sync=20(#11329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ refactor: use approximate number of documents to improve performance * šŸ”§ refactor: unittests for approximate document count in meilisearch sync * refactor: limits persentage based on approximate total count & one more test case --- .../src/models/plugins/mongoMeili.spec.ts | 135 ++++++++++++++++++ .../src/models/plugins/mongoMeili.ts | 13 +- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts index 0bf3ab75ef..8f4ee87aaf 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts @@ -129,4 +129,139 @@ describe('Meilisearch Mongoose plugin', () => { expect(mockAddDocuments).not.toHaveBeenCalled(); }); + + describe('estimatedDocumentCount usage in syncWithMeili', () => { + test('syncWithMeili completes successfully with estimatedDocumentCount', async () => { + // Clear any previous documents + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Create test documents + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation 1', + endpoint: EModelEndpoint.openAI, + }); + + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation 2', + endpoint: EModelEndpoint.openAI, + }); + + // Trigger sync - should use estimatedDocumentCount internally + await expect(conversationModel.syncWithMeili()).resolves.not.toThrow(); + + // Verify documents were processed + expect(mockAddDocuments).toHaveBeenCalled(); + }); + + test('syncWithMeili handles empty collection correctly', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + // Verify collection is empty + const count = await messageModel.estimatedDocumentCount(); + expect(count).toBe(0); + + // Sync should complete without error even with 0 estimated documents + await expect(messageModel.syncWithMeili()).resolves.not.toThrow(); + }); + + test('estimatedDocumentCount returns count for non-empty collection', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Create documents + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test 1', + endpoint: EModelEndpoint.openAI, + }); + + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test 2', + endpoint: EModelEndpoint.openAI, + }); + + const estimatedCount = await conversationModel.estimatedDocumentCount(); + expect(estimatedCount).toBeGreaterThanOrEqual(2); + }); + + test('estimatedDocumentCount is available on model', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + + // Verify the method exists and is callable + expect(typeof messageModel.estimatedDocumentCount).toBe('function'); + + // Should be able to call it + const result = await messageModel.estimatedDocumentCount(); + expect(typeof result).toBe('number'); + expect(result).toBeGreaterThanOrEqual(0); + }); + + test('syncWithMeili handles mix of syncable and TTL documents correctly', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + mockAddDocuments.mockClear(); + + // Create syncable documents (expiredAt: null) + await messageModel.create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + expiredAt: null, + }); + + await messageModel.create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: false, + expiredAt: null, + }); + + // Create TTL documents (expiredAt set to a date) + await messageModel.create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + expiredAt: new Date(), + }); + + await messageModel.create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: false, + expiredAt: new Date(), + }); + + // estimatedDocumentCount should count all documents (both syncable and TTL) + const estimatedCount = await messageModel.estimatedDocumentCount(); + expect(estimatedCount).toBe(4); + + // Actual syncable documents (expiredAt: null) + const syncableCount = await messageModel.countDocuments({ expiredAt: null }); + expect(syncableCount).toBe(2); + + // Sync should complete successfully even though estimated count is higher than processed count + await expect(messageModel.syncWithMeili()).resolves.not.toThrow(); + + // Only syncable documents should be indexed (2 documents, not 4) + // The mock should be called once per batch, and we have 2 documents + expect(mockAddDocuments).toHaveBeenCalled(); + + // Verify that only 2 documents were indexed (the syncable ones) + const indexedCount = await messageModel.countDocuments({ _meiliIndex: true }); + expect(indexedCount).toBe(2); + }); + }); }); diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 2d56303395..548a7d2f1a 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -189,8 +189,10 @@ const createMeiliMongooseModel = ({ query._id = { $gt: options.resumeFromId }; } - // Get total count for progress tracking - const totalCount = await this.countDocuments(query); + // Get approximate total count for progress tracking + const approxTotalCount = await this.estimatedDocumentCount(); + logger.info(`[syncWithMeili] Approximate total number of documents to sync: ${approxTotalCount}`); + let processedCount = 0; // First, handle documents that need to be removed from Meili @@ -239,8 +241,11 @@ const createMeiliMongooseModel = ({ updateOps = []; // Log progress - const progress = Math.round((processedCount / totalCount) * 100); - logger.info(`[syncWithMeili] Progress: ${progress}% (${processedCount}/${totalCount})`); + // Calculate percentage based on approximate total count sometimes might lead to more than 100% + // the difference is very small and acceptable for progress tracking + const percent = Math.round((processedCount / approxTotalCount) * 100); + const progress = Math.min(percent, 100); + logger.info(`[syncWithMeili] Progress: ${progress}% (count: ${processedCount})`); // Add delay to prevent overwhelming resources if (delayMs > 0) { From a95fea19bbbb1ec861891aca4fb4a9878e1226be Mon Sep 17 00:00:00 2001 From: David Newman Date: Wed, 14 Jan 2026 04:01:11 +1000 Subject: [PATCH 021/282] =?UTF-8?q?=F0=9F=8C=85=20fix:=20Agent=20Avatar=20?= =?UTF-8?q?S3=20URL=20Refresh=20Pagination=20and=20Persistence=20(#11323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refresh all S3 avatars for this user's accessible agent set, not the first page * Cleaner debug messages * Log errors as errors * refactor: avatar refresh logic to process agents in batches and improve error handling. Introduced new utility functions for refreshing S3 avatars and updating agent records. Updated tests to cover various scenarios including cache hits, user ownership checks, and error handling. Added constants for maximum refresh limits. * refactor: update avatar refresh logic to allow users with VIEW access to refresh avatars for all accessible agents. Removed checks for agent ownership and author presence, and updated related tests to reflect new behavior. * chore: Remove YouTube toolkit due to #11331 --------- Co-authored-by: Danny Avila --- api/server/controllers/agents/v1.js | 79 ++--- api/server/controllers/agents/v1.spec.js | 361 ++++++++++++++++++++++- api/server/services/start/tools.js | 3 +- packages/api/src/agents/avatars.spec.ts | 228 ++++++++++++++ packages/api/src/agents/avatars.ts | 122 ++++++++ packages/api/src/agents/index.ts | 1 + 6 files changed, 743 insertions(+), 51 deletions(-) create mode 100644 packages/api/src/agents/avatars.spec.ts create mode 100644 packages/api/src/agents/avatars.ts diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 19a185279e..9f0a4a2279 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -5,7 +5,9 @@ const { logger } = require('@librechat/data-schemas'); const { agentCreateSchema, agentUpdateSchema, + refreshListAvatars, mergeAgentOcrConversion, + MAX_AVATAR_REFRESH_AGENTS, convertOcrToContextInPlace, } = require('@librechat/api'); const { @@ -56,46 +58,6 @@ const systemTools = { const MAX_SEARCH_LEN = 100; const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -/** - * Opportunistically refreshes S3-backed avatars for agent list responses. - * Only list responses are refreshed because they're the highest-traffic surface and - * the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes - * via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most. - * @param {Array} agents - Agents being enriched with S3-backed avatars - * @param {string} userId - User identifier used for the cache refresh key - */ -const refreshListAvatars = async (agents, userId) => { - if (!agents?.length) { - return; - } - - const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); - const refreshKey = `${userId}:agents_list`; - const alreadyChecked = await cache.get(refreshKey); - if (alreadyChecked) { - return; - } - - await Promise.all( - agents.map(async (agent) => { - if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) { - return; - } - - try { - const newPath = await refreshS3Url(agent.avatar); - if (newPath && newPath !== agent.avatar.filepath) { - agent.avatar = { ...agent.avatar, filepath: newPath }; - } - } catch (err) { - logger.debug('[/Agents] Avatar refresh error for list item', err); - } - }), - ); - - await cache.set(refreshKey, true, Time.THIRTY_MINUTES); -}; - /** * Creates an Agent. * @route POST /Agents @@ -544,6 +506,35 @@ const getListAgentsHandler = async (req, res) => { requiredPermissions: PermissionBits.VIEW, }); + /** + * Refresh all S3 avatars for this user's accessible agent set (not only the current page) + * This addresses page-size limits preventing refresh of agents beyond the first page + */ + const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); + const refreshKey = `${userId}:agents_avatar_refresh`; + const alreadyChecked = await cache.get(refreshKey); + if (alreadyChecked) { + logger.debug('[/Agents] S3 avatar refresh already checked, skipping'); + } else { + try { + const fullList = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, + limit: MAX_AVATAR_REFRESH_AGENTS, + after: null, + }); + await refreshListAvatars({ + agents: fullList?.data ?? [], + userId, + refreshS3Url, + updateAgent, + }); + await cache.set(refreshKey, true, Time.THIRTY_MINUTES); + } catch (err) { + logger.error('[/Agents] Error refreshing avatars for full list: %o', err); + } + } + // Use the new ACL-aware function const data = await getListAgentsByAccess({ accessibleIds, @@ -571,15 +562,9 @@ const getListAgentsHandler = async (req, res) => { return agent; }); - // Opportunistically refresh S3 avatar URLs for list results with caching - try { - await refreshListAvatars(data.data, req.user.id); - } catch (err) { - logger.debug('[/Agents] Skipping avatar refresh for list', err); - } return res.json(data); } catch (error) { - logger.error('[/Agents] Error listing Agents', error); + logger.error('[/Agents] Error listing Agents: %o', error); res.status(500).json({ error: error.message }); } }; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 1bcf6c2fa3..8b2a57d903 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1,8 +1,9 @@ const mongoose = require('mongoose'); -const { v4: uuidv4 } = require('uuid'); const { nanoid } = require('nanoid'); -const { MongoMemoryServer } = require('mongodb-memory-server'); +const { v4: uuidv4 } = require('uuid'); const { agentSchema } = require('@librechat/data-schemas'); +const { FileSources } = require('librechat-data-provider'); +const { MongoMemoryServer } = require('mongodb-memory-server'); // Only mock the dependencies that are not database-related jest.mock('~/server/services/Config', () => ({ @@ -54,6 +55,15 @@ jest.mock('~/models', () => ({ getCategoriesWithCounts: jest.fn(), })); +// Mock cache for S3 avatar refresh tests +const mockCache = { + get: jest.fn(), + set: jest.fn(), +}; +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => mockCache), +})); + const { createAgent: createAgentHandler, updateAgent: updateAgentHandler, @@ -65,6 +75,8 @@ const { findPubliclyAccessibleResources, } = require('~/server/services/PermissionService'); +const { refreshS3Url } = require('~/server/services/Files/S3/crud'); + /** * @type {import('mongoose').Model} */ @@ -1207,4 +1219,349 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].is_promoted).toBe(true); }); }); + + describe('S3 Avatar Refresh', () => { + let userA, userB; + let agentWithS3Avatar, agentWithLocalAvatar, agentOwnedByOther; + + beforeEach(async () => { + await Agent.deleteMany({}); + jest.clearAllMocks(); + + // Reset cache mock + mockCache.get.mockResolvedValue(false); + mockCache.set.mockResolvedValue(undefined); + + userA = new mongoose.Types.ObjectId(); + userB = new mongoose.Types.ObjectId(); + + // Create agent with S3 avatar owned by userA + agentWithS3Avatar = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent with S3 Avatar', + description: 'Has S3 avatar', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: 'old-s3-path.jpg', + }, + versions: [ + { + name: 'Agent with S3 Avatar', + description: 'Has S3 avatar', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + // Create agent with local avatar owned by userA + agentWithLocalAvatar = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent with Local Avatar', + description: 'Has local avatar', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: 'local', + filepath: 'local-path.jpg', + }, + versions: [ + { + name: 'Agent with Local Avatar', + description: 'Has local avatar', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + // Create agent with S3 avatar owned by userB + agentOwnedByOther = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent Owned By Other', + description: 'Owned by userB', + provider: 'openai', + model: 'gpt-4', + author: userB, + avatar: { + source: FileSources.s3, + filepath: 'other-s3-path.jpg', + }, + versions: [ + { + name: 'Agent Owned By Other', + description: 'Owned by userB', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + }); + + test('should skip avatar refresh if cache hit', async () => { + mockCache.get.mockResolvedValue(true); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should not call refreshS3Url when cache hit + expect(refreshS3Url).not.toHaveBeenCalled(); + }); + + test('should refresh and persist S3 avatars on cache miss', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-s3-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify S3 URL was refreshed + expect(refreshS3Url).toHaveBeenCalled(); + + // Verify cache was set + expect(mockCache.set).toHaveBeenCalled(); + + // Verify response was returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should refresh avatars for all accessible agents (VIEW permission)', async () => { + mockCache.get.mockResolvedValue(false); + // User A has access to both their own agent and userB's agent + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id, agentOwnedByOther._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should be called for both agents - any user with VIEW access can refresh + expect(refreshS3Url).toHaveBeenCalledTimes(2); + }); + + test('should skip non-S3 avatars', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithLocalAvatar._id, agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should only be called for S3 avatar agent + expect(refreshS3Url).toHaveBeenCalledTimes(1); + }); + + test('should not update if S3 URL unchanged', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + // Return the same path - no update needed + refreshS3Url.mockResolvedValue('old-s3-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify refreshS3Url was called + expect(refreshS3Url).toHaveBeenCalled(); + + // Response should still be returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should handle S3 refresh errors gracefully', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockRejectedValue(new Error('S3 error')); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + // Should not throw - handles error gracefully + await expect(getListAgentsHandler(mockReq, mockRes)).resolves.not.toThrow(); + + // Response should still be returned + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should process agents in batches', async () => { + mockCache.get.mockResolvedValue(false); + + // Create 25 agents (should be processed in batches of 20) + const manyAgents = []; + for (let i = 0; i < 25; i++) { + const agent = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: `Agent ${i}`, + description: `Agent ${i} description`, + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: `path${i}.jpg`, + }, + versions: [ + { + name: `Agent ${i}`, + description: `Agent ${i} description`, + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + manyAgents.push(agent); + } + + const allAgentIds = manyAgents.map((a) => a._id); + findAccessibleResources.mockResolvedValue(allAgentIds); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockImplementation((avatar) => + Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')), + ); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // All 25 should be processed + expect(refreshS3Url).toHaveBeenCalledTimes(25); + }); + + test('should skip agents without id or author', async () => { + mockCache.get.mockResolvedValue(false); + + // Create agent without proper id field (edge case) + const agentWithoutId = await Agent.create({ + id: `agent_${nanoid(12)}`, + name: 'Agent without ID field', + description: 'Testing', + provider: 'openai', + model: 'gpt-4', + author: userA, + avatar: { + source: FileSources.s3, + filepath: 'test-path.jpg', + }, + versions: [ + { + name: 'Agent without ID field', + description: 'Testing', + provider: 'openai', + model: 'gpt-4', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + findAccessibleResources.mockResolvedValue([agentWithoutId._id, agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Should still complete without errors + expect(mockRes.json).toHaveBeenCalled(); + }); + + test('should use MAX_AVATAR_REFRESH_AGENTS limit for full list query', async () => { + mockCache.get.mockResolvedValue(false); + findAccessibleResources.mockResolvedValue([]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Verify that the handler completed successfully + expect(mockRes.json).toHaveBeenCalled(); + }); + }); }); diff --git a/api/server/services/start/tools.js b/api/server/services/start/tools.js index 4fd35755bc..dd2d69b274 100644 --- a/api/server/services/start/tools.js +++ b/api/server/services/start/tools.js @@ -5,7 +5,7 @@ const { Calculator } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); const { zodToJsonSchema } = require('zod-to-json-schema'); const { Tools, ImageVisionTool } = require('librechat-data-provider'); -const { getToolkitKey, oaiToolkit, ytToolkit, geminiToolkit } = require('@librechat/api'); +const { getToolkitKey, oaiToolkit, geminiToolkit } = require('@librechat/api'); const { toolkits } = require('~/app/clients/tools/manifest'); /** @@ -83,7 +83,6 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) const basicToolInstances = [ new Calculator(), ...Object.values(oaiToolkit), - ...Object.values(ytToolkit), ...Object.values(geminiToolkit), ]; for (const toolInstance of basicToolInstances) { diff --git a/packages/api/src/agents/avatars.spec.ts b/packages/api/src/agents/avatars.spec.ts new file mode 100644 index 0000000000..ac97964837 --- /dev/null +++ b/packages/api/src/agents/avatars.spec.ts @@ -0,0 +1,228 @@ +import { FileSources } from 'librechat-data-provider'; +import type { Agent, AgentAvatar, AgentModelParameters } from 'librechat-data-provider'; +import type { RefreshS3UrlFn, UpdateAgentFn } from './avatars'; +import { + MAX_AVATAR_REFRESH_AGENTS, + AVATAR_REFRESH_BATCH_SIZE, + refreshListAvatars, +} from './avatars'; + +describe('refreshListAvatars', () => { + let mockRefreshS3Url: jest.MockedFunction; + let mockUpdateAgent: jest.MockedFunction; + const userId = 'user123'; + + beforeEach(() => { + mockRefreshS3Url = jest.fn(); + mockUpdateAgent = jest.fn(); + }); + + const createAgent = (overrides: Partial = {}): Agent => ({ + _id: 'obj1', + id: 'agent1', + name: 'Test Agent', + author: userId, + description: 'Test', + created_at: Date.now(), + avatar: { + source: FileSources.s3, + filepath: 'old-path.jpg', + }, + instructions: null, + provider: 'openai', + model: 'gpt-4', + model_parameters: {} as AgentModelParameters, + ...overrides, + }); + + it('should return empty stats for empty agents array', async () => { + const stats = await refreshListAvatars({ + agents: [], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.updated).toBe(0); + expect(mockRefreshS3Url).not.toHaveBeenCalled(); + expect(mockUpdateAgent).not.toHaveBeenCalled(); + }); + + it('should skip non-S3 avatars', async () => { + const agent = createAgent({ + avatar: { source: 'local', filepath: 'local-path.jpg' } as AgentAvatar, + }); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.not_s3).toBe(1); + expect(stats.updated).toBe(0); + expect(mockRefreshS3Url).not.toHaveBeenCalled(); + }); + + it('should skip agents without id', async () => { + const agent = createAgent({ id: '' }); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.no_id).toBe(1); + expect(mockRefreshS3Url).not.toHaveBeenCalled(); + }); + + it('should refresh avatars for agents owned by other users (VIEW access)', async () => { + const agent = createAgent({ author: 'otherUser' }); + mockRefreshS3Url.mockResolvedValue('new-path.jpg'); + mockUpdateAgent.mockResolvedValue({}); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.updated).toBe(1); + expect(mockRefreshS3Url).toHaveBeenCalled(); + expect(mockUpdateAgent).toHaveBeenCalled(); + }); + + it('should refresh and persist S3 avatars', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockResolvedValue('new-path.jpg'); + mockUpdateAgent.mockResolvedValue({}); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.updated).toBe(1); + expect(mockRefreshS3Url).toHaveBeenCalledWith(agent.avatar); + expect(mockUpdateAgent).toHaveBeenCalledWith( + { id: 'agent1' }, + { avatar: { filepath: 'new-path.jpg', source: FileSources.s3 } }, + { updatingUserId: userId, skipVersioning: true }, + ); + }); + + it('should not update if S3 URL unchanged', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockResolvedValue('old-path.jpg'); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.no_change).toBe(1); + expect(stats.updated).toBe(0); + expect(mockUpdateAgent).not.toHaveBeenCalled(); + }); + + it('should handle S3 refresh errors gracefully', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockRejectedValue(new Error('S3 error')); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.s3_error).toBe(1); + expect(stats.updated).toBe(0); + }); + + it('should handle database persist errors gracefully', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockResolvedValue('new-path.jpg'); + mockUpdateAgent.mockRejectedValue(new Error('DB error')); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.persist_error).toBe(1); + expect(stats.updated).toBe(0); + }); + + it('should process agents in batches', async () => { + const agents = Array.from({ length: 25 }, (_, i) => + createAgent({ + _id: `obj${i}`, + id: `agent${i}`, + avatar: { source: FileSources.s3, filepath: `path${i}.jpg` }, + }), + ); + + mockRefreshS3Url.mockImplementation((avatar) => + Promise.resolve(avatar.filepath.replace('.jpg', '-new.jpg')), + ); + mockUpdateAgent.mockResolvedValue({}); + + const stats = await refreshListAvatars({ + agents, + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.updated).toBe(25); + expect(mockRefreshS3Url).toHaveBeenCalledTimes(25); + expect(mockUpdateAgent).toHaveBeenCalledTimes(25); + }); + + it('should track mixed statistics correctly', async () => { + const agents = [ + createAgent({ id: 'agent1' }), + createAgent({ id: 'agent2', author: 'otherUser' }), + createAgent({ + id: 'agent3', + avatar: { source: 'local', filepath: 'local.jpg' } as AgentAvatar, + }), + createAgent({ id: '' }), // no id + ]; + + mockRefreshS3Url.mockResolvedValue('new-path.jpg'); + mockUpdateAgent.mockResolvedValue({}); + + const stats = await refreshListAvatars({ + agents, + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.updated).toBe(2); // agent1 and agent2 (other user's agent now refreshed) + expect(stats.not_s3).toBe(1); // agent3 + expect(stats.no_id).toBe(1); // agent with empty id + }); +}); + +describe('Constants', () => { + it('should export MAX_AVATAR_REFRESH_AGENTS as 1000', () => { + expect(MAX_AVATAR_REFRESH_AGENTS).toBe(1000); + }); + + it('should export AVATAR_REFRESH_BATCH_SIZE as 20', () => { + expect(AVATAR_REFRESH_BATCH_SIZE).toBe(20); + }); +}); diff --git a/packages/api/src/agents/avatars.ts b/packages/api/src/agents/avatars.ts new file mode 100644 index 0000000000..7c92f352b2 --- /dev/null +++ b/packages/api/src/agents/avatars.ts @@ -0,0 +1,122 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import type { Agent, AgentAvatar } from 'librechat-data-provider'; + +const MAX_AVATAR_REFRESH_AGENTS = 1000; +const AVATAR_REFRESH_BATCH_SIZE = 20; + +export { MAX_AVATAR_REFRESH_AGENTS, AVATAR_REFRESH_BATCH_SIZE }; + +export type RefreshS3UrlFn = (avatar: AgentAvatar) => Promise; + +export type UpdateAgentFn = ( + searchParams: { id: string }, + updateData: { avatar: AgentAvatar }, + options: { updatingUserId: string; skipVersioning: boolean }, +) => Promise; + +export type RefreshListAvatarsParams = { + agents: Agent[]; + userId: string; + refreshS3Url: RefreshS3UrlFn; + updateAgent: UpdateAgentFn; +}; + +export type RefreshStats = { + updated: number; + not_s3: number; + no_id: number; + no_change: number; + s3_error: number; + persist_error: number; +}; + +/** + * Opportunistically refreshes S3-backed avatars for agent list responses. + * Processes agents in batches to prevent database connection pool exhaustion. + * Only list responses are refreshed because they're the highest-traffic surface and + * the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes + * so we refresh once per interval at most. + * + * Any user with VIEW access to an agent can refresh its avatar URL. This ensures + * avatars remain accessible even when the owner hasn't logged in recently. + * The agents array should already be filtered to only include agents the user can access. + */ +export const refreshListAvatars = async ({ + agents, + userId, + refreshS3Url, + updateAgent, +}: RefreshListAvatarsParams): Promise => { + const stats: RefreshStats = { + updated: 0, + not_s3: 0, + no_id: 0, + no_change: 0, + s3_error: 0, + persist_error: 0, + }; + + if (!agents?.length) { + return stats; + } + + logger.debug('[refreshListAvatars] Refreshing S3 avatars for agents: %d', agents.length); + + for (let i = 0; i < agents.length; i += AVATAR_REFRESH_BATCH_SIZE) { + const batch = agents.slice(i, i + AVATAR_REFRESH_BATCH_SIZE); + + await Promise.all( + batch.map(async (agent) => { + if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) { + stats.not_s3++; + return; + } + + if (!agent?.id) { + logger.debug( + '[refreshListAvatars] Skipping S3 avatar refresh for agent: %s, ID is not set', + agent._id, + ); + stats.no_id++; + return; + } + + try { + logger.debug('[refreshListAvatars] Refreshing S3 avatar for agent: %s', agent._id); + const newPath = await refreshS3Url(agent.avatar); + + if (newPath && newPath !== agent.avatar.filepath) { + try { + await updateAgent( + { id: agent.id }, + { + avatar: { + filepath: newPath, + source: agent.avatar.source, + }, + }, + { + updatingUserId: userId, + skipVersioning: true, + }, + ); + stats.updated++; + } catch (persistErr) { + logger.error('[refreshListAvatars] Avatar refresh persist error: %o', persistErr); + stats.persist_error++; + } + } else { + stats.no_change++; + } + } catch (err) { + logger.error('[refreshListAvatars] S3 avatar refresh error: %o', err); + stats.s3_error++; + } + }), + ); + } + + logger.info('[refreshListAvatars] Avatar refresh summary: %o', stats); + return stats; +}; diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 7f4be5f0ec..5efc22a397 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,3 +1,4 @@ +export * from './avatars'; export * from './chain'; export * from './edges'; export * from './initialize'; From 9d5e80d7a39517d0aab5bfb089c4526810d21c8d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 13 Jan 2026 14:13:06 -0500 Subject: [PATCH 022/282] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20UI/UX=20?= =?UTF-8?q?for=20Known=20Server-sent=20Errors=20(#11343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/SSE/useResumableSSE.ts | 73 ++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index ee04bcf32f..831bf042ad 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -4,11 +4,13 @@ import { SSE } from 'sse.js'; import { useSetRecoilState } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; import { - apiBaseUrl, request, Constants, QueryKeys, + ErrorTypes, + apiBaseUrl, createPayload, + ViolationTypes, LocalStorageKeys, removeNullishValues, } from 'librechat-data-provider'; @@ -334,8 +336,11 @@ export default function useResumableSSE( }); /** - * Error event - fired on actual network failures (non-200, connection lost, etc.) - * This should trigger reconnection with exponential backoff, except for 404 errors. + * Error event handler - handles BOTH: + * 1. HTTP-level errors (responseCode present) - 404, 401, network failures + * 2. Server-sent error events (event: error with data) - known errors like ViolationTypes/ErrorTypes + * + * Order matters: check responseCode first since HTTP errors may also include data */ sse.addEventListener('error', async (e: MessageEvent) => { (startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch(); @@ -347,7 +352,6 @@ export default function useResumableSSE( if (responseCode === 404) { console.log('[ResumableSSE] Stream not found (404) - job completed or expired'); sse.close(); - // Optimistically remove from active jobs since job is gone removeActiveJob(currentStreamId); setIsSubmitting(false); setShowStopButton(false); @@ -356,8 +360,6 @@ export default function useResumableSSE( return; } - console.log('[ResumableSSE] Stream error (network failure) - will attempt reconnect'); - // Check for 401 and try to refresh token (same pattern as useSSE) if (responseCode === 401) { try { @@ -366,7 +368,6 @@ export default function useResumableSSE( if (!newToken) { throw new Error('Token refresh failed.'); } - // Update headers on same SSE instance and retry (like useSSE) sse.headers = { Authorization: `Bearer ${newToken}`, }; @@ -378,6 +379,64 @@ export default function useResumableSSE( } } + /** + * Server-sent error event (event: error with data) - no responseCode. + * These are known errors (ErrorTypes, ViolationTypes) that should be displayed to user. + * Only check e.data if there's no HTTP responseCode, since HTTP errors may also have body data. + */ + if (!responseCode && e.data) { + console.log('[ResumableSSE] Server-sent error event received:', e.data); + sse.close(); + removeActiveJob(currentStreamId); + + try { + const errorData = JSON.parse(e.data); + const errorString = errorData.error ?? errorData.message ?? JSON.stringify(errorData); + + // Check if it's a known error type (ViolationTypes or ErrorTypes) + let isKnownError = false; + try { + const parsed = + typeof errorString === 'string' ? JSON.parse(errorString) : errorString; + const errorType = parsed?.type ?? parsed?.code; + if (errorType) { + const violationValues = Object.values(ViolationTypes) as string[]; + const errorTypeValues = Object.values(ErrorTypes) as string[]; + isKnownError = + violationValues.includes(errorType) || errorTypeValues.includes(errorType); + } + } catch { + // Not JSON or parsing failed - treat as generic error + } + + console.log('[ResumableSSE] Error type check:', { isKnownError, errorString }); + + // Display the error to user via errorHandler + errorHandler({ + data: { text: errorString } as unknown as Parameters[0]['data'], + submission: currentSubmission as EventSubmission, + }); + } catch (parseError) { + console.error('[ResumableSSE] Failed to parse server error:', parseError); + errorHandler({ + data: { text: e.data } as unknown as Parameters[0]['data'], + submission: currentSubmission as EventSubmission, + }); + } + + setIsSubmitting(false); + setShowStopButton(false); + setStreamId(null); + reconnectAttemptRef.current = 0; + return; + } + + // Network failure or unknown HTTP error - attempt reconnection with backoff + console.log('[ResumableSSE] Stream error (network failure) - will attempt reconnect', { + responseCode, + hasData: !!e.data, + }); + if (reconnectAttemptRef.current < MAX_RETRIES) { // Increment counter BEFORE close() so abort handler knows we're reconnecting reconnectAttemptRef.current++; From 8d74fcd44a4480b3996c83f5927c0bffedda0950 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 10:38:01 -0500 Subject: [PATCH 023/282] =?UTF-8?q?=F0=9F=93=A6=20chore:=20npm=20audit=20f?= =?UTF-8?q?ix=20(#11346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded several dependencies including browserify-sign (4.2.3 to 4.2.5), hono (4.11.3 to 4.11.4), parse-asn1 (5.1.7 to 5.1.9), pbkdf2 (3.1.3 to 3.1.5), and ripemd160 (2.0.2 to 2.0.3). - Adjusted engine requirements for compatibility with older Node.js versions. - Cleaned up unnecessary nested module entries for pbkdf2. --- package-lock.json | 141 +++++++++++++++++++++++++--------------------- 1 file changed, 77 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18e55217d6..5c3c3a9cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22544,25 +22544,24 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz", - "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", "dev": true, "license": "ISC", "dependencies": { - "bn.js": "^5.2.1", - "browserify-rsa": "^4.1.0", + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.5", - "hash-base": "~3.0", + "elliptic": "^6.6.1", "inherits": "^2.0.4", - "parse-asn1": "^5.1.7", + "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" }, "engines": { - "node": ">= 0.12" + "node": ">= 0.10" } }, "node_modules/browserify-sign/node_modules/isarray": { @@ -28113,9 +28112,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hono": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", - "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", "peer": true, "engines": { @@ -34561,17 +34560,16 @@ } }, "node_modules/parse-asn1": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz", - "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", "dev": true, "license": "ISC", "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", - "hash-base": "~3.0", - "pbkdf2": "^3.1.2", + "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" }, "engines": { @@ -34896,55 +34894,21 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pbkdf2": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", - "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", "dev": true, "license": "MIT", "dependencies": { - "create-hash": "~1.1.3", + "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "ripemd160": "=2.0.1", + "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", - "sha.js": "^2.4.11", - "to-buffer": "^1.2.0" + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=0.12" - } - }, - "node_modules/pbkdf2/node_modules/create-hash": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", - "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "sha.js": "^2.4.0" - } - }, - "node_modules/pbkdf2/node_modules/hash-base": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", - "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1" - } - }, - "node_modules/pbkdf2/node_modules/ripemd160": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", - "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hash-base": "^2.0.0", - "inherits": "^2.0.1" + "node": ">= 0.10" } }, "node_modules/peek-readable": { @@ -38527,16 +38491,65 @@ } }, "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", "dev": true, "license": "MIT", "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" } }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", From 39a227a59f19b3c272c7a1c489970e7c578d8fea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:39:46 -0500 Subject: [PATCH 024/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/nb/translation.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index 153e4255bf..15d77af35d 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "Noe bĆør legges inn her. Tomt felt.", - "chat_direction_right_to_left": "Noe bĆør legges inn her. Tomt felt.", + "chat_direction_left_to_right": "Venstre til hĆøyre", + "chat_direction_right_to_left": "HĆøyre til venstre", "com_a11y_ai_composing": "KI-en skriver fortsatt.", "com_a11y_end": "KI-en har fullfĆørt svaret sitt.", "com_a11y_start": "KI-en har begynt Ć„ svare.", @@ -372,7 +372,7 @@ "com_files_number_selected": "{{0}} av {{1}} valgt", "com_files_preparing_download": "Forbereder nedlasting ...", "com_files_sharepoint_picker_title": "Velg filer", - "com_files_table": "[Plassholder: Tabell over filer]", + "com_files_table": "Fil-tabell", "com_files_upload_local_machine": "Fra lokal datamaskin", "com_files_upload_sharepoint": "Fra SharePoint", "com_generated_files": "Genererte filer:", @@ -813,7 +813,7 @@ "com_ui_download_backup": "Last ned reservekoder", "com_ui_download_backup_tooltip": "FĆør du fortsetter, last ned reservekodene dine. Du vil trenge dem for Ć„ fĆ„ tilgang igjen hvis du mister autentiseringsenheten din.", "com_ui_download_error": "Feil ved nedlasting av fil. Filen kan ha blitt slettet.", - "com_ui_drag_drop": "Dra og slipp filer her, eller klikk for Ć„ velge.", + "com_ui_drag_drop": "Dra og slipp fil(er) her, eller klikk for Ć„ velge.", "com_ui_dropdown_variables": "Nedtrekksvariabler:", "com_ui_dropdown_variables_info": "Opprett egendefinerte nedtrekksmenyer for promptene dine: `{{variabelnavn:valg1|valg2|valg3}}`", "com_ui_duplicate": "Dupliser", From b5e4c763afda0001b05097b47a704501ec6cec4c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 14:07:58 -0500 Subject: [PATCH 025/282] =?UTF-8?q?=F0=9F=94=80=20refactor:=20Endpoint=20C?= =?UTF-8?q?heck=20for=20File=20Uploads=20in=20Images=20Route=20(#11352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed the endpoint check from `isAgentsEndpoint` to `isAssistantsEndpoint` to adjust the logic for processing file uploads. - Reordered the import statements for better organization. --- api/server/routes/files/images.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/server/routes/files/images.js b/api/server/routes/files/images.js index b8be413f4f..8072612a69 100644 --- a/api/server/routes/files/images.js +++ b/api/server/routes/files/images.js @@ -2,11 +2,11 @@ const path = require('path'); const fs = require('fs').promises; const express = require('express'); const { logger } = require('@librechat/data-schemas'); -const { isAgentsEndpoint } = require('librechat-data-provider'); +const { isAssistantsEndpoint } = require('librechat-data-provider'); const { - filterFile, - processImageFile, processAgentFileUpload, + processImageFile, + filterFile, } = require('~/server/services/Files/process'); const router = express.Router(); @@ -21,7 +21,7 @@ router.post('/', async (req, res) => { metadata.temp_file_id = metadata.file_id; metadata.file_id = req.file_id; - if (isAgentsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { + if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) { return await processAgentFileUpload({ req, res, metadata }); } From 9562f9297a80e4b39622b6035f1144d9278bed0a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Jan 2026 22:02:57 -0500 Subject: [PATCH 026/282] =?UTF-8?q?=F0=9F=AA=A8=20fix:=20Bedrock=20Provide?= =?UTF-8?q?r=20Support=20for=20Memory=20Agent=20(#11353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Bedrock provider support in memory processing - Introduced support for the Bedrock provider in the memory processing logic. - Updated the handling of instructions to ensure they are included in user messages for Bedrock, while maintaining the standard approach for other providers. - Added tests to verify the correct behavior for both Bedrock and non-Bedrock providers regarding instruction handling. * refactor: Bedrock memory processing logic - Improved handling of the first message in Bedrock memory processing to ensure proper content is used. - Added logging for cases where the first message content is not a string. - Adjusted the processed messages to include the original content or fallback to a new HumanMessage if no messages are present. * feat: Enhance Bedrock configuration handling in memory processing - Added logic to set the temperature to 1 when using the Bedrock provider with thinking enabled. - Ensured compatibility with additional model request fields for improved memory processing. --- packages/api/src/agents/memory.spec.ts | 91 +++++++++++++++++++++++++- packages/api/src/agents/memory.ts | 62 ++++++++++++++++-- 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index 1b5242f78d..ca5a34ce05 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -1,17 +1,42 @@ import { Types } from 'mongoose'; -import type { Response } from 'express'; import { Run } from '@librechat/agents'; import type { IUser } from '@librechat/data-schemas'; -import { createSafeUser } from '~/utils/env'; +import type { Response } from 'express'; import { processMemory } from './memory'; jest.mock('~/stream/GenerationJobManager'); + +const mockCreateSafeUser = jest.fn((user) => ({ + id: user?.id, + email: user?.email, + name: user?.name, + username: user?.username, +})); + +const mockResolveHeaders = jest.fn((opts) => { + const headers = opts.headers || {}; + const user = opts.user || {}; + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + let resolved = value as string; + resolved = resolved.replace(/\$\{(\w+)\}/g, (_match, envVar) => process.env[envVar] || ''); + resolved = resolved.replace(/\{\{LIBRECHAT_USER_EMAIL\}\}/g, user.email || ''); + resolved = resolved.replace(/\{\{LIBRECHAT_USER_ID\}\}/g, user.id || ''); + result[key] = resolved; + } + return result; +}); + jest.mock('~/utils', () => ({ Tokenizer: { getTokenCount: jest.fn(() => 10), }, + createSafeUser: (user: unknown) => mockCreateSafeUser(user), + resolveHeaders: (opts: unknown) => mockResolveHeaders(opts), })); +const { createSafeUser } = jest.requireMock('~/utils'); + jest.mock('@librechat/agents', () => ({ Run: { create: jest.fn(() => ({ @@ -20,6 +45,7 @@ jest.mock('@librechat/agents', () => ({ }, Providers: { OPENAI: 'openai', + BEDROCK: 'bedrock', }, GraphEvents: { TOOL_END: 'tool_end', @@ -295,4 +321,65 @@ describe('Memory Agent Header Resolution', () => { expect(safeUser).toHaveProperty('id'); expect(safeUser).toHaveProperty('email'); }); + + it('should include instructions in user message for Bedrock provider', async () => { + const llmConfig = { + provider: 'bedrock', + model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + }; + + const { HumanMessage } = await import('@langchain/core/messages'); + const testMessage = new HumanMessage('test chat content'); + + await processMemory({ + res: mockRes, + userId: 'user-123', + setMemory: mockMemoryMethods.setMemory, + deleteMemory: mockMemoryMethods.deleteMemory, + messages: [testMessage], + memory: 'existing memory', + messageId: 'msg-123', + conversationId: 'conv-123', + validKeys: ['preferences'], + instructions: 'test instructions', + llmConfig, + user: testUser, + }); + + expect(Run.create as jest.Mock).toHaveBeenCalled(); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0]; + + // For Bedrock, instructions should NOT be passed to graphConfig + expect(runConfig.graphConfig.instructions).toBeUndefined(); + expect(runConfig.graphConfig.additional_instructions).toBeUndefined(); + }); + + it('should pass instructions to graphConfig for non-Bedrock providers', async () => { + const llmConfig = { + provider: 'openai', + model: 'gpt-4o-mini', + }; + + await processMemory({ + res: mockRes, + userId: 'user-123', + setMemory: mockMemoryMethods.setMemory, + deleteMemory: mockMemoryMethods.deleteMemory, + messages: [], + memory: 'existing memory', + messageId: 'msg-123', + conversationId: 'conv-123', + validKeys: ['preferences'], + instructions: 'test instructions', + llmConfig, + user: testUser, + }); + + expect(Run.create as jest.Mock).toHaveBeenCalled(); + const runConfig = (Run.create as jest.Mock).mock.calls[0][0]; + + // For non-Bedrock providers, instructions should be passed to graphConfig + expect(runConfig.graphConfig.instructions).toBe('test instructions'); + expect(runConfig.graphConfig.additional_instructions).toBeDefined(); + }); }); diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index dcf26a8666..6a46ab68c3 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { tool } from '@langchain/core/tools'; import { Tools } from 'librechat-data-provider'; import { logger } from '@librechat/data-schemas'; +import { HumanMessage } from '@langchain/core/messages'; import { Run, Providers, GraphEvents } from '@librechat/agents'; import type { OpenAIClientOptions, @@ -13,13 +14,12 @@ import type { ToolEndData, LLMConfig, } from '@librechat/agents'; -import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; import type { ObjectId, MemoryMethods, IUser } from '@librechat/data-schemas'; +import type { TAttachment, MemoryArtifact } from 'librechat-data-provider'; import type { BaseMessage, ToolMessage } from '@langchain/core/messages'; import type { Response as ServerResponse } from 'express'; import { GenerationJobManager } from '~/stream/GenerationJobManager'; -import { resolveHeaders, createSafeUser } from '~/utils/env'; -import { Tokenizer } from '~/utils'; +import { Tokenizer, resolveHeaders, createSafeUser } from '~/utils'; type RequiredMemoryMethods = Pick< MemoryMethods, @@ -369,6 +369,19 @@ ${memory ?? 'No existing memories'}`; } } + // Handle Bedrock with thinking enabled - temperature must be 1 + const bedrockConfig = finalLLMConfig as { + additionalModelRequestFields?: { thinking?: unknown }; + temperature?: number; + }; + if ( + llmConfig?.provider === Providers.BEDROCK && + bedrockConfig.additionalModelRequestFields?.thinking != null && + bedrockConfig.temperature != null + ) { + (finalLLMConfig as unknown as Record).temperature = 1; + } + const llmConfigWithHeaders = finalLLMConfig as OpenAIClientOptions; if (llmConfigWithHeaders?.configuration?.defaultHeaders != null) { llmConfigWithHeaders.configuration.defaultHeaders = resolveHeaders({ @@ -383,14 +396,51 @@ ${memory ?? 'No existing memories'}`; [GraphEvents.TOOL_END]: new BasicToolEndHandler(memoryCallback), }; + /** + * For Bedrock provider, include instructions in the user message instead of as a system prompt. + * Bedrock's Converse API requires conversations to start with a user message, not a system message. + * Other providers can use the standard system prompt approach. + */ + const isBedrock = llmConfig?.provider === Providers.BEDROCK; + + let graphInstructions: string | undefined = instructions; + let graphAdditionalInstructions: string | undefined = memoryStatus; + let processedMessages = messages; + + if (isBedrock) { + const combinedInstructions = [instructions, memoryStatus].filter(Boolean).join('\n\n'); + + if (messages.length > 0) { + const firstMessage = messages[0]; + const originalContent = + typeof firstMessage.content === 'string' ? firstMessage.content : ''; + + if (typeof firstMessage.content !== 'string') { + logger.warn( + 'Bedrock memory processing: First message has non-string content, using empty string', + ); + } + + const bedrockUserMessage = new HumanMessage( + `${combinedInstructions}\n\n${originalContent}`, + ); + processedMessages = [bedrockUserMessage, ...messages.slice(1)]; + } else { + processedMessages = [new HumanMessage(combinedInstructions)]; + } + + graphInstructions = undefined; + graphAdditionalInstructions = undefined; + } + const run = await Run.create({ runId: messageId, graphConfig: { type: 'standard', llmConfig: finalLLMConfig, tools: [memoryTool, deleteMemoryTool], - instructions, - additional_instructions: memoryStatus, + instructions: graphInstructions, + additional_instructions: graphAdditionalInstructions, toolEnd: true, }, customHandlers, @@ -410,7 +460,7 @@ ${memory ?? 'No existing memories'}`; } as const; const inputs = { - messages, + messages: processedMessages, }; const content = await run.processStream(inputs, config); if (content) { From bb0fa3b7f7efb460fcdc90520a704890eeb48dd9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 21:24:49 -0500 Subject: [PATCH 027/282] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Cleanup=20Unuse?= =?UTF-8?q?d=20Packages=20(#11369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove unused 'diff' package from dependencies * chore: update undici package to version 7.18.2 * chore: remove unused '@types/diff' package from dependencies * chore: remove unused '@types/diff' package from package.json and package-lock.json --- api/package.json | 2 +- package-lock.json | 28 +++++----------------------- packages/api/package.json | 4 +--- packages/data-schemas/package.json | 1 - 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/api/package.json b/api/package.json index 0881070652..9ceb9b624c 100644 --- a/api/package.json +++ b/api/package.json @@ -108,7 +108,7 @@ "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", - "undici": "^7.10.0", + "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.22.4" diff --git a/package-lock.json b/package-lock.json index 5c3c3a9cf9..d9fd999fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,7 +122,7 @@ "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", - "undici": "^7.10.0", + "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.22.4" @@ -20369,12 +20369,6 @@ "@types/ms": "*" } }, - "node_modules/@types/diff": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz", - "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==", - "dev": true - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -25001,15 +24995,6 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -41136,9 +41121,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -43115,7 +43100,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^12.1.2", "@types/bun": "^1.2.15", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", @@ -43151,7 +43135,6 @@ "@smithy/node-http-handler": "^4.4.5", "axios": "^1.12.1", "connect-redis": "^8.1.0", - "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^5.1.0", "express-session": "^1.18.2", @@ -43171,7 +43154,7 @@ "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", - "undici": "^7.10.0", + "undici": "^7.18.2", "zod": "^3.22.4" } }, @@ -45573,7 +45556,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", diff --git a/packages/api/package.json b/packages/api/package.json index 5f5576e293..d8a06aad2b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,7 +55,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^12.1.2", "@types/bun": "^1.2.15", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", @@ -94,7 +93,6 @@ "@smithy/node-http-handler": "^4.4.5", "axios": "^1.12.1", "connect-redis": "^8.1.0", - "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^5.1.0", "express-session": "^1.18.2", @@ -114,7 +112,7 @@ "node-fetch": "2.7.0", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", - "undici": "^7.10.0", + "undici": "^7.18.2", "zod": "^3.22.4" } } diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 49c29f8561..eb143c0dd6 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -44,7 +44,6 @@ "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", - "@types/diff": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.0", From 476882455e44cfb0d8d63c4a6c07d80524b24656 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:23:23 -0500 Subject: [PATCH 028/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index f123294a8d..f17bb9cb46 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -533,6 +533,7 @@ "com_nav_log_out": "IzrakstÄ«ties", "com_nav_long_audio_warning": "Garāku tekstu apstrāde prasÄ«s ilgāku laiku.", "com_nav_maximize_chat_space": "Maksimāli izmantot sarunu telpas izmērus", + "com_nav_mcp_access_revoked": "MCP servera piekļuve veiksmÄ«gi atsaukta.", "com_nav_mcp_configure_server": "Konfigurēt {{0}}", "com_nav_mcp_connect": "Savienot", "com_nav_mcp_connect_server": "Savienot {{0}}", From 81f4af55b5cc80bf9d1db6445aaf7ca35315f08e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 22:48:48 -0500 Subject: [PATCH 029/282] =?UTF-8?q?=F0=9F=AA=A8=20feat:=20Anthropic=20Beta?= =?UTF-8?q?=20Support=20for=20Bedrock=20(#11371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪨 feat: Anthropic Beta Support for Bedrock - Updated the Bedrock input parser to dynamically generate `anthropic_beta` headers based on the model identifier. - Added a new utility function `getBedrockAnthropicBetaHeaders` to determine applicable headers for various Anthropic models. - Modified existing tests to reflect changes in expected `anthropic_beta` values, including new test cases for full model IDs. * test: Update Bedrock Input Parser Tests for Beta Headers - Modified the test case for explicit thinking configuration to reflect the addition of `anthropic_beta` headers. - Ensured that the test now verifies the presence of specific beta header values in the additional model request fields. --- packages/data-provider/specs/bedrock.spec.ts | 52 +++++++++++++++----- packages/data-provider/src/bedrock.ts | 35 ++++++++++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 2a0de6937a..c731d18d5e 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -14,7 +14,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-sonnet-4 model', () => { + test('should match anthropic.claude-sonnet-4 model with 1M context header', () => { const input = { model: 'anthropic.claude-sonnet-4', }; @@ -22,10 +22,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-opus-5 model', () => { + test('should match anthropic.claude-opus-5 model without 1M context header', () => { const input = { model: 'anthropic.claude-opus-5', }; @@ -36,7 +39,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-haiku-6 model', () => { + test('should match anthropic.claude-haiku-6 model without 1M context header', () => { const input = { model: 'anthropic.claude-haiku-6', }; @@ -47,7 +50,7 @@ describe('bedrockInputParser', () => { expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); }); - test('should match anthropic.claude-4-sonnet model', () => { + test('should match anthropic.claude-4-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4-sonnet', }; @@ -55,10 +58,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-4.5-sonnet model', () => { + test('should match anthropic.claude-4.5-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4.5-sonnet', }; @@ -66,10 +72,13 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); - test('should match anthropic.claude-4-7-sonnet model', () => { + test('should match anthropic.claude-4-7-sonnet model with 1M context header', () => { const input = { model: 'anthropic.claude-4-7-sonnet', }; @@ -77,7 +86,24 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBe(true); expect(additionalFields.thinkingBudget).toBe(2000); - expect(additionalFields.anthropic_beta).toEqual(['output-128k-2025-02-19']); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); + }); + + test('should match anthropic.claude-sonnet-4-20250514-v1:0 with full model ID', () => { + const input = { + model: 'anthropic.claude-sonnet-4-20250514-v1:0', + }; + const result = bedrockInputParser.parse(input) as BedrockConverseInput; + const additionalFields = result.additionalModelRequestFields as Record; + expect(additionalFields.thinking).toBe(true); + expect(additionalFields.thinkingBudget).toBe(2000); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); test('should not match non-Claude models', () => { @@ -110,7 +136,7 @@ describe('bedrockInputParser', () => { expect(additionalFields?.anthropic_beta).toBeUndefined(); }); - test('should respect explicit thinking configuration', () => { + test('should respect explicit thinking configuration but still add beta headers', () => { const input = { model: 'anthropic.claude-sonnet-4', thinking: false, @@ -119,6 +145,10 @@ describe('bedrockInputParser', () => { const additionalFields = result.additionalModelRequestFields as Record; expect(additionalFields.thinking).toBeUndefined(); expect(additionalFields.thinkingBudget).toBeUndefined(); + expect(additionalFields.anthropic_beta).toEqual([ + 'output-128k-2025-02-19', + 'context-1m-2025-08-07', + ]); }); test('should respect custom thinking budget', () => { diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index b37fdc25e1..4df6bd6b65 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -15,6 +15,36 @@ type AnthropicInput = BedrockConverseInput & { AnthropicReasoning; }; +/** + * Gets the appropriate anthropic_beta headers for Bedrock Anthropic models. + * Bedrock uses `anthropic_beta` (with underscore) in additionalModelRequestFields. + * + * @param model - The Bedrock model identifier (e.g., "anthropic.claude-sonnet-4-20250514-v1:0") + * @returns Array of beta header strings, or empty array if not applicable + */ +function getBedrockAnthropicBetaHeaders(model: string): string[] { + const betaHeaders: string[] = []; + + const isClaudeThinkingModel = + model.includes('anthropic.claude-3-7-sonnet') || + /anthropic\.claude-(?:[4-9](?:\.\d+)?(?:-\d+)?-(?:sonnet|opus|haiku)|(?:sonnet|opus|haiku)-[4-9])/.test( + model, + ); + + const isSonnet4PlusModel = + /anthropic\.claude-(?:sonnet-[4-9]|[4-9](?:\.\d+)?(?:-\d+)?-sonnet)/.test(model); + + if (isClaudeThinkingModel) { + betaHeaders.push('output-128k-2025-02-19'); + } + + if (isSonnet4PlusModel) { + betaHeaders.push('context-1m-2025-08-07'); + } + + return betaHeaders; +} + export const bedrockInputSchema = s.tConversationSchema .pick({ /* LibreChat params; optionType: 'conversation' */ @@ -138,7 +168,10 @@ export const bedrockInputParser = s.tConversationSchema additionalFields.thinkingBudget = 2000; } if (typedData.model.includes('anthropic.')) { - additionalFields.anthropic_beta = ['output-128k-2025-02-19']; + const betaHeaders = getBedrockAnthropicBetaHeaders(typedData.model); + if (betaHeaders.length > 0) { + additionalFields.anthropic_beta = betaHeaders; + } } } else if (additionalFields.thinking != null || additionalFields.thinkingBudget != null) { delete additionalFields.thinking; From c378e777efa23479b27db729955087f86f500514 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 15 Jan 2026 23:02:03 -0500 Subject: [PATCH 030/282] =?UTF-8?q?=F0=9F=AA=B5=20refactor:=20Preserve=20J?= =?UTF-8?q?ob=20Error=20State=20for=20Late=20Stream=20Subscribers=20(#1137?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪵 refactor: Preserve job error state for late stream subscribers * šŸ”§ fix: Enhance error handling for late subscribers in GenerationJobManager - Implemented a cleanup strategy for error jobs to prevent immediate deletion, allowing late clients to receive error messages. - Updated job status handling to prioritize error notifications over completion events. - Added integration tests to verify error preservation and proper notification to late subscribers, including scenarios with Redis support. --- .../api/src/stream/GenerationJobManager.ts | 64 +++- ...ationJobManager.stream_integration.spec.ts | 276 ++++++++++++++++++ .../implementations/InMemoryEventTransport.ts | 6 +- 3 files changed, 335 insertions(+), 11 deletions(-) diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 44b38f48f6..13544fc445 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -33,6 +33,7 @@ export interface GenerationJobManagerOptions { * @property readyPromise - Resolves immediately (legacy, kept for API compatibility) * @property resolveReady - Function to resolve readyPromise * @property finalEvent - Cached final event for late subscribers + * @property errorEvent - Cached error event for late subscribers (errors before client connects) * @property syncSent - Whether sync event was sent (reset when all subscribers leave) * @property earlyEventBuffer - Buffer for events emitted before first subscriber connects * @property hasSubscriber - Whether at least one subscriber has connected @@ -47,6 +48,7 @@ interface RuntimeJobState { readyPromise: Promise; resolveReady: () => void; finalEvent?: t.ServerSentEvent; + errorEvent?: string; syncSent: boolean; earlyEventBuffer: t.ServerSentEvent[]; hasSubscriber: boolean; @@ -421,6 +423,7 @@ class GenerationJobManagerClass { earlyEventBuffer: [], hasSubscriber: false, finalEvent, + errorEvent: jobData.error, }; this.runtimeState.set(streamId, runtime); @@ -510,6 +513,8 @@ class GenerationJobManagerClass { /** * Mark job as complete. * If cleanupOnComplete is true (default), immediately cleans up job resources. + * Exception: Jobs with errors are NOT immediately deleted to allow late-connecting + * clients to receive the error (race condition where error occurs before client connects). * Note: eventTransport is NOT cleaned up here to allow the final event to be * fully transmitted. It will be cleaned up when subscribers disconnect or * by the periodic cleanup job. @@ -527,7 +532,29 @@ class GenerationJobManagerClass { this.jobStore.clearContentState(streamId); this.runStepBuffers?.delete(streamId); - // Immediate cleanup if configured (default: true) + // For error jobs, DON'T delete immediately - keep around so late-connecting + // clients can receive the error. This handles the race condition where error + // occurs before client connects to SSE stream. + // + // Cleanup strategy: Error jobs are cleaned up by periodic cleanup (every 60s) + // via jobStore.cleanup() which checks for jobs with status 'error' and + // completedAt set. The TTL is configurable via jobStore options (default: 0, + // meaning cleanup on next interval). This gives clients ~60s to connect and + // receive the error before the job is removed. + if (error) { + await this.jobStore.updateJob(streamId, { + status: 'error', + completedAt: Date.now(), + error, + }); + // Keep runtime state so subscribe() can access errorEvent + logger.debug( + `[GenerationJobManager] Job completed with error (keeping for late subscribers): ${streamId}`, + ); + return; + } + + // Immediate cleanup if configured (default: true) - only for successful completions if (this._cleanupOnComplete) { this.runtimeState.delete(streamId); // Don't cleanup eventTransport here - let the done event fully transmit first. @@ -536,9 +563,8 @@ class GenerationJobManagerClass { } else { // Only update status if keeping the job around await this.jobStore.updateJob(streamId, { - status: error ? 'error' : 'complete', + status: 'complete', completedAt: Date.now(), - error, }); } @@ -678,14 +704,22 @@ class GenerationJobManagerClass { const jobData = await this.jobStore.getJob(streamId); - // If job already complete, send final event + // If job already complete/error, send final event or error + // Error status takes precedence to ensure errors aren't misreported as successes setImmediate(() => { - if ( - runtime.finalEvent && - jobData && - ['complete', 'error', 'aborted'].includes(jobData.status) - ) { - onDone?.(runtime.finalEvent); + if (jobData && ['complete', 'error', 'aborted'].includes(jobData.status)) { + // Check for error status FIRST and prioritize error handling + if (jobData.status === 'error' && (runtime.errorEvent || jobData.error)) { + const errorToSend = runtime.errorEvent ?? jobData.error; + if (errorToSend) { + logger.debug( + `[GenerationJobManager] Sending stored error to late subscriber: ${streamId}`, + ); + onError?.(errorToSend); + } + } else if (runtime.finalEvent) { + onDone?.(runtime.finalEvent); + } } }); @@ -986,8 +1020,18 @@ class GenerationJobManagerClass { /** * Emit an error event. + * Stores the error for late-connecting subscribers (race condition where error + * occurs before client connects to SSE stream). */ emitError(streamId: string, error: string): void { + const runtime = this.runtimeState.get(streamId); + if (runtime) { + runtime.errorEvent = error; + } + // Persist error to job store for cross-replica consistency + this.jobStore.updateJob(streamId, { error }).catch((err) => { + logger.error(`[GenerationJobManager] Failed to persist error:`, err); + }); this.eventTransport.emitError(streamId, error); } diff --git a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts index 4471d8c95d..e3ea16c8f0 100644 --- a/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/GenerationJobManager.stream_integration.spec.ts @@ -796,6 +796,282 @@ describe('GenerationJobManager Integration Tests', () => { }); }); + describe('Error Preservation for Late Subscribers', () => { + /** + * These tests verify the fix for the race condition where errors + * (like INPUT_LENGTH) occur before the SSE client connects. + * + * Problem: Error → emitError → completeJob → job deleted → client connects → 404 + * Fix: Store error, don't delete job immediately, send error to late subscriber + */ + + test('should store error in emitError for late-connecting subscribers', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-store-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + // Emit error (no subscribers yet - simulates race condition) + GenerationJobManager.emitError(streamId, errorMessage); + + // Wait for async job store update + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify error is stored in job store + const job = await GenerationJobManager.getJob(streamId); + expect(job?.error).toBe(errorMessage); + + await GenerationJobManager.destroy(); + }); + + test('should NOT delete job immediately when completeJob is called with error', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, // Default behavior + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-no-delete-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = 'Test error message'; + + // Complete with error + await GenerationJobManager.completeJob(streamId, errorMessage); + + // Job should still exist (not deleted) + const hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(true); + + // Job should have error status + const job = await GenerationJobManager.getJob(streamId); + expect(job?.status).toBe('error'); + expect(job?.error).toBe(errorMessage); + + await GenerationJobManager.destroy(); + }); + + test('should send stored error to late-connecting subscriber', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-late-sub-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + // Simulate race condition: error occurs before client connects + GenerationJobManager.emitError(streamId, errorMessage); + await GenerationJobManager.completeJob(streamId, errorMessage); + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Now client connects (late subscriber) + let receivedError: string | undefined; + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, // onChunk + () => {}, // onDone + (error) => { + receivedError = error; + }, // onError + ); + + expect(subscription).not.toBeNull(); + + // Wait for setImmediate in subscribe to fire + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Late subscriber should receive the stored error + expect(receivedError).toBe(errorMessage); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + }); + + test('should prioritize error status over finalEvent in subscribe', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore({ ttlAfterComplete: 60000 }), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-priority-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const errorMessage = 'Error should take priority'; + + // Emit error and complete with error + GenerationJobManager.emitError(streamId, errorMessage); + await GenerationJobManager.completeJob(streamId, errorMessage); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Subscribe and verify error is received (not a done event) + let receivedError: string | undefined; + let receivedDone = false; + + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, + () => { + receivedDone = true; + }, + (error) => { + receivedError = error; + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Error should be received, not done + expect(receivedError).toBe(errorMessage); + expect(receivedDone).toBe(false); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + }); + + test('should handle error preservation in Redis mode (cross-replica)', async () => { + if (!ioredisClient) { + console.warn('Redis not available, skipping test'); + return; + } + + const { createStreamServices } = await import('../createStreamServices'); + const { RedisJobStore } = await import('../implementations/RedisJobStore'); + + // === Replica A: Creates job and emits error === + const replicaAJobStore = new RedisJobStore(ioredisClient); + await replicaAJobStore.initialize(); + + const streamId = `redis-error-${Date.now()}`; + const errorMessage = '{ "type": "INPUT_LENGTH", "info": "234856 / 172627" }'; + + await replicaAJobStore.createJob(streamId, 'user-1'); + await replicaAJobStore.updateJob(streamId, { + status: 'error', + error: errorMessage, + completedAt: Date.now(), + }); + + // === Replica B: Fresh manager receives client connection === + jest.resetModules(); + const { GenerationJobManager } = await import('../GenerationJobManager'); + + const services = createStreamServices({ + useRedis: true, + redisClient: ioredisClient, + }); + + GenerationJobManager.configure({ + ...services, + cleanupOnComplete: false, + }); + await GenerationJobManager.initialize(); + + // Client connects to Replica B (job created on Replica A) + let receivedError: string | undefined; + const subscription = await GenerationJobManager.subscribe( + streamId, + () => {}, + () => {}, + (error) => { + receivedError = error; + }, + ); + + expect(subscription).not.toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Error should be loaded from Redis and sent to subscriber + expect(receivedError).toBe(errorMessage); + + subscription?.unsubscribe(); + await GenerationJobManager.destroy(); + await replicaAJobStore.destroy(); + }); + + test('error jobs should be cleaned up by periodic cleanup after TTL', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + // Use a very short TTL for testing + const jobStore = new InMemoryJobStore({ ttlAfterComplete: 100 }); + + GenerationJobManager.configure({ + jobStore, + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: true, + }); + + await GenerationJobManager.initialize(); + + const streamId = `error-cleanup-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Complete with error + await GenerationJobManager.completeJob(streamId, 'Test error'); + + // Job should exist immediately after error + let hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(true); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Trigger cleanup + await jobStore.cleanup(); + + // Job should be cleaned up after TTL + hasJob = await GenerationJobManager.hasJob(streamId); + expect(hasJob).toBe(false); + + await GenerationJobManager.destroy(); + }); + }); + describe('createStreamServices Auto-Detection', () => { test('should auto-detect Redis when USE_REDIS is true', async () => { if (!ioredisClient) { diff --git a/packages/api/src/stream/implementations/InMemoryEventTransport.ts b/packages/api/src/stream/implementations/InMemoryEventTransport.ts index fd9c65e239..39b3d6029d 100644 --- a/packages/api/src/stream/implementations/InMemoryEventTransport.ts +++ b/packages/api/src/stream/implementations/InMemoryEventTransport.ts @@ -79,7 +79,11 @@ export class InMemoryEventTransport implements IEventTransport { emitError(streamId: string, error: string): void { const state = this.streams.get(streamId); - state?.emitter.emit('error', error); + // Only emit if there are listeners - Node.js throws on unhandled 'error' events + // This is intentional for the race condition where error occurs before client connects + if (state?.emitter.listenerCount('error') ?? 0 > 0) { + state?.emitter.emit('error', error); + } } getSubscriberCount(streamId: string): number { From 02d75b24a455dc72e5a527eefdcbcdd6c444ed77 Mon Sep 17 00:00:00 2001 From: Andrei Blizorukov <55080535+ablizorukov@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:30:00 +0100 Subject: [PATCH 031/282] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20improved?= =?UTF-8?q?=20retry=20logic=20during=20meili=20sync=20&=20improved=20batch?= =?UTF-8?q?ing=20(#11373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ› ļø fix: unreliable retry logic during meili sync in case of interruption šŸ› ļø fix: exclude temporary documents from the count on startup for meili sync šŸ› ļø refactor: improved meili index cleanup before sync * fix: don't swallow the exception to prevent indefinite loop fix: update log messages for more clarity fix: more test coverage for exception handling --- .../src/models/plugins/mongoMeili.spec.ts | 361 ++++++++++++++++++ .../src/models/plugins/mongoMeili.ts | 195 ++++------ 2 files changed, 441 insertions(+), 115 deletions(-) diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts index 8f4ee87aaf..25e8d54cc1 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts @@ -6,10 +6,20 @@ import { createMessageModel } from '~/models/message'; import { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; const mockAddDocuments = jest.fn(); +const mockAddDocumentsInBatches = jest.fn(); +const mockUpdateDocuments = jest.fn(); +const mockDeleteDocument = jest.fn(); +const mockDeleteDocuments = jest.fn(); +const mockGetDocument = jest.fn(); const mockIndex = jest.fn().mockReturnValue({ getRawInfo: jest.fn(), updateSettings: jest.fn(), addDocuments: mockAddDocuments, + addDocumentsInBatches: mockAddDocumentsInBatches, + updateDocuments: mockUpdateDocuments, + deleteDocument: mockDeleteDocument, + deleteDocuments: mockDeleteDocuments, + getDocument: mockGetDocument, getDocuments: jest.fn().mockReturnValue({ results: [] }), }); jest.mock('meilisearch', () => { @@ -42,6 +52,11 @@ describe('Meilisearch Mongoose plugin', () => { beforeEach(() => { mockAddDocuments.mockClear(); + mockAddDocumentsInBatches.mockClear(); + mockUpdateDocuments.mockClear(); + mockDeleteDocument.mockClear(); + mockDeleteDocuments.mockClear(); + mockGetDocument.mockClear(); }); afterAll(async () => { @@ -264,4 +279,350 @@ describe('Meilisearch Mongoose plugin', () => { expect(indexedCount).toBe(2); }); }); + + describe('New batch processing and retry functionality', () => { + test('processSyncBatch uses addDocumentsInBatches', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + mockAddDocuments.mockClear(); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Run sync which should call processSyncBatch internally + await conversationModel.syncWithMeili(); + + // Verify addDocumentsInBatches was called (new batch method) + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + }); + + test('addObjectToMeili retries on failure', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + + // Mock addDocuments to fail twice then succeed + mockAddDocuments + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({}); + + // Create a document which triggers addObjectToMeili + await conversationModel.create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Retry', + endpoint: EModelEndpoint.openAI, + }); + + // Wait for async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify addDocuments was called multiple times due to retries + expect(mockAddDocuments).toHaveBeenCalledTimes(3); + }); + + test('getSyncProgress returns accurate progress information', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Insert documents directly to control the _meiliIndex flag + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: null, + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Not Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + const progress = await conversationModel.getSyncProgress(); + + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(1); + expect(progress.isComplete).toBe(false); + }); + + test('getSyncProgress excludes TTL documents from counts', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + + // Insert syncable documents (expiredAt: null) + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Syncable Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: null, + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Syncable Not Indexed', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Insert TTL documents (expiredAt set) - these should NOT be counted + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'TTL Document 1', + endpoint: EModelEndpoint.openAI, + _meiliIndex: true, + expiredAt: new Date(), + }); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'TTL Document 2', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: new Date(), + }); + + const progress = await conversationModel.getSyncProgress(); + + // Only syncable documents should be counted (2 total, 1 indexed) + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(1); + expect(progress.isComplete).toBe(false); + }); + + test('getSyncProgress shows completion when all syncable documents are indexed', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + // All syncable documents are indexed + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: true, + expiredAt: null, + }); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: false, + _meiliIndex: true, + expiredAt: null, + }); + + // Add TTL document - should not affect completion status + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: new Date(), + }); + + const progress = await messageModel.getSyncProgress(); + + expect(progress.totalDocuments).toBe(2); + expect(progress.totalProcessed).toBe(2); + expect(progress.isComplete).toBe(true); + }); + }); + + describe('Error handling in processSyncBatch', () => { + test('syncWithMeili fails when processSyncBatch encounters addDocumentsInBatches error', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Insert a document to sync + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Mock addDocumentsInBatches to fail + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('MeiliSearch connection error')); + + // Sync should throw the error + await expect(conversationModel.syncWithMeili()).rejects.toThrow( + 'MeiliSearch connection error', + ); + + // Verify the error was logged + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + + // Document should NOT be marked as indexed since sync failed + // Note: direct collection.insertOne doesn't set default values, so _meiliIndex may be undefined + const doc = await conversationModel.findOne({}); + expect(doc?._meiliIndex).not.toBe(true); + }); + + test('syncWithMeili fails when processSyncBatch encounters updateMany error', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Insert a document + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + // Mock addDocumentsInBatches to succeed but simulate updateMany failure + mockAddDocumentsInBatches.mockResolvedValueOnce({}); + + // Spy on updateMany and make it fail + const updateManySpy = jest + .spyOn(conversationModel, 'updateMany') + .mockRejectedValueOnce(new Error('Database connection error')); + + // Sync should throw the error + await expect(conversationModel.syncWithMeili()).rejects.toThrow('Database connection error'); + + expect(updateManySpy).toHaveBeenCalled(); + + // Restore original implementation + updateManySpy.mockRestore(); + }); + + test('processSyncBatch logs error and throws when addDocumentsInBatches fails', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('Network timeout')); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: null, + }); + + const indexMock = mockIndex(); + const documents = await messageModel.find({ _meiliIndex: false }).lean(); + + // Should throw the error + await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow( + 'Network timeout', + ); + + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + }); + + test('processSyncBatch handles empty document array gracefully', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + const indexMock = mockIndex(); + + // Should not throw with empty array + await expect(conversationModel.processSyncBatch(indexMock, [])).resolves.not.toThrow(); + + // Should not call addDocumentsInBatches + expect(mockAddDocumentsInBatches).not.toHaveBeenCalled(); + }); + + test('syncWithMeili stops processing when batch fails and does not process remaining documents', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + // Create multiple documents + for (let i = 0; i < 5; i++) { + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: `Test Conversation ${i}`, + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + } + + // Mock addDocumentsInBatches to fail on first call + mockAddDocumentsInBatches.mockRejectedValueOnce(new Error('First batch failed')); + + // Sync should fail on the first batch + await expect(conversationModel.syncWithMeili()).rejects.toThrow('First batch failed'); + + // Should have attempted only once before failing + expect(mockAddDocumentsInBatches).toHaveBeenCalledTimes(1); + + // No documents should be indexed since sync failed + const indexedCount = await conversationModel.countDocuments({ _meiliIndex: true }); + expect(indexedCount).toBe(0); + }); + + test('error in processSyncBatch is properly logged before being thrown', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + + const testError = new Error('Test error for logging'); + mockAddDocumentsInBatches.mockRejectedValueOnce(testError); + + await messageModel.collection.insertOne({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + _meiliIndex: false, + expiredAt: null, + }); + + const indexMock = mockIndex(); + const documents = await messageModel.find({ _meiliIndex: false }).lean(); + + // Should throw the same error that was passed to it + await expect(messageModel.processSyncBatch(indexMock, documents)).rejects.toThrow(testError); + }); + + test('syncWithMeili properly propagates processSyncBatch errors', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + + await conversationModel.collection.insertOne({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test', + endpoint: EModelEndpoint.openAI, + _meiliIndex: false, + expiredAt: null, + }); + + const customError = new Error('Custom sync error'); + mockAddDocumentsInBatches.mockRejectedValueOnce(customError); + + // The error should propagate all the way up + await expect(conversationModel.syncWithMeili()).rejects.toThrow('Custom sync error'); + }); + }); }); diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 548a7d2f1a..2551c35d99 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -50,17 +50,11 @@ interface _DocumentWithMeiliIndex extends Document { export type DocumentWithMeiliIndex = _DocumentWithMeiliIndex & IConversation & Partial; export interface SchemaWithMeiliMethods extends Model { - syncWithMeili(options?: { resumeFromId?: string }): Promise; + syncWithMeili(): Promise; getSyncProgress(): Promise; processSyncBatch( index: Index, documents: Array>, - updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }>, ): Promise; cleanupMeiliIndex( index: Index, @@ -156,8 +150,8 @@ const createMeiliMongooseModel = ({ * Get the current sync progress */ static async getSyncProgress(this: SchemaWithMeiliMethods): Promise { - const totalDocuments = await this.countDocuments(); - const indexedDocuments = await this.countDocuments({ _meiliIndex: true }); + const totalDocuments = await this.countDocuments({ expiredAt: null }); + const indexedDocuments = await this.countDocuments({ expiredAt: null, _meiliIndex: true }); return { totalProcessed: indexedDocuments, @@ -167,106 +161,79 @@ const createMeiliMongooseModel = ({ } /** - * Synchronizes the data between the MongoDB collection and the MeiliSearch index. - * Now uses streaming and batching to reduce memory usage. - */ - static async syncWithMeili( - this: SchemaWithMeiliMethods, - options?: { resumeFromId?: string }, - ): Promise { + * Synchronizes data between the MongoDB collection and the MeiliSearch index by + * incrementally indexing only documents where `expiredAt` is `null` and `_meiliIndex` is `false` + * (i.e., non-expired documents that have not yet been indexed). + * */ + static async syncWithMeili(this: SchemaWithMeiliMethods): Promise { + const startTime = Date.now(); + const { batchSize, delayMs } = syncConfig; + + const collectionName = primaryKey === 'messageId' ? 'messages' : 'conversations'; + logger.info( + `[syncWithMeili] Starting sync for ${collectionName} with batch size ${batchSize}`, + ); + + // Get approximate total count for raw estimation, the sync should not overcome this number + const approxTotalCount = await this.estimatedDocumentCount(); + logger.info( + `[syncWithMeili] Approximate total number of all ${collectionName}: ${approxTotalCount}`, + ); + try { - const startTime = Date.now(); - const { batchSize, delayMs } = syncConfig; - - logger.info( - `[syncWithMeili] Starting sync for ${primaryKey === 'messageId' ? 'messages' : 'conversations'} with batch size ${batchSize}`, - ); - - // Build query with resume capability - // Do not sync TTL documents - const query: FilterQuery = { expiredAt: null }; - if (options?.resumeFromId) { - query._id = { $gt: options.resumeFromId }; - } - - // Get approximate total count for progress tracking - const approxTotalCount = await this.estimatedDocumentCount(); - logger.info(`[syncWithMeili] Approximate total number of documents to sync: ${approxTotalCount}`); - - let processedCount = 0; - // First, handle documents that need to be removed from Meili + logger.info(`[syncWithMeili] Starting cleanup of Meili index ${index.uid} before sync`); await this.cleanupMeiliIndex(index, primaryKey, batchSize, delayMs); - - // Process MongoDB documents in batches using cursor - const cursor = this.find(query) - .select(attributesToIndex.join(' ') + ' _meiliIndex') - .sort({ _id: 1 }) - .batchSize(batchSize) - .cursor(); - - const format = (doc: Record) => - _.omitBy(_.pick(doc, attributesToIndex), (v, k) => k.startsWith('$')); - - let documentBatch: Array> = []; - let updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }> = []; - - // Process documents in streaming fashion - for await (const doc of cursor) { - const typedDoc = doc.toObject() as unknown as Record; - const formatted = format(typedDoc); - - // Check if document needs indexing - if (!typedDoc._meiliIndex) { - documentBatch.push(formatted); - updateOps.push({ - updateOne: { - filter: { _id: typedDoc._id }, - update: { $set: { _meiliIndex: true } }, - }, - }); - } - - processedCount++; - - // Process batch when it reaches the configured size - if (documentBatch.length >= batchSize) { - await this.processSyncBatch(index, documentBatch, updateOps); - documentBatch = []; - updateOps = []; - - // Log progress - // Calculate percentage based on approximate total count sometimes might lead to more than 100% - // the difference is very small and acceptable for progress tracking - const percent = Math.round((processedCount / approxTotalCount) * 100); - const progress = Math.min(percent, 100); - logger.info(`[syncWithMeili] Progress: ${progress}% (count: ${processedCount})`); - - // Add delay to prevent overwhelming resources - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - } - - // Process remaining documents - if (documentBatch.length > 0) { - await this.processSyncBatch(index, documentBatch, updateOps); - } - - const duration = Date.now() - startTime; - logger.info( - `[syncWithMeili] Completed sync for ${primaryKey === 'messageId' ? 'messages' : 'conversations'} in ${duration}ms`, - ); + logger.info(`[syncWithMeili] Completed cleanup of Meili index: ${index.uid}`); } catch (error) { - logger.error('[syncWithMeili] Error during sync:', error); + logger.error('[syncWithMeili] Error during cleanup Meili before sync:', error); throw error; } + + let processedCount = 0; + let hasMore = true; + + while (hasMore) { + const query: FilterQuery = { + expiredAt: null, + _meiliIndex: false, + }; + + try { + const documents = await this.find(query) + .select(attributesToIndex.join(' ') + ' _meiliIndex') + .limit(batchSize) + .lean(); + + // Check if there are more documents to process + if (documents.length === 0) { + logger.info('[syncWithMeili] No more documents to process'); + break; + } + + // Process the batch + await this.processSyncBatch(index, documents); + processedCount += documents.length; + logger.info(`[syncWithMeili] Processed: ${processedCount}`); + + if (documents.length < batchSize) { + hasMore = false; + } + + // Add delay to prevent overwhelming resources + if (hasMore && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } catch (error) { + logger.error('[syncWithMeili] Error processing documents batch:', error); + throw error; + } + } + + const duration = Date.now() - startTime; + logger.info( + `[syncWithMeili] Completed sync for ${collectionName}. Processed ${processedCount} documents in ${duration}ms`, + ); } /** @@ -276,28 +243,26 @@ const createMeiliMongooseModel = ({ this: SchemaWithMeiliMethods, index: Index, documents: Array>, - updateOps: Array<{ - updateOne: { - filter: Record; - update: { $set: { _meiliIndex: boolean } }; - }; - }>, ): Promise { if (documents.length === 0) { return; } + // Format documents for MeiliSearch + const formattedDocs = documents.map((doc) => + _.omitBy(_.pick(doc, attributesToIndex), (_v, k) => k.startsWith('$')), + ); + try { // Add documents to MeiliSearch - await index.addDocuments(documents); + await index.addDocumentsInBatches(formattedDocs); // Update MongoDB to mark documents as indexed - if (updateOps.length > 0) { - await this.collection.bulkWrite(updateOps); - } + const docsIds = documents.map((doc) => doc._id); + await this.updateMany({ _id: { $in: docsIds } }, { $set: { _meiliIndex: true } }); } catch (error) { logger.error('[processSyncBatch] Error processing batch:', error); - // Don't throw - allow sync to continue with other documents + throw error; } } @@ -336,7 +301,7 @@ const createMeiliMongooseModel = ({ // Delete documents that don't exist in MongoDB const toDelete = meiliIds.filter((id) => !existingIds.has(id)); if (toDelete.length > 0) { - await Promise.all(toDelete.map((id) => index.deleteDocument(id as string))); + await index.deleteDocuments(toDelete.map(String)); logger.debug(`[cleanupMeiliIndex] Deleted ${toDelete.length} orphaned documents`); } From f7893d9507986c378f913892c0f830ac7212aa25 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 16 Jan 2026 17:45:18 -0500 Subject: [PATCH 032/282] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Update=20Z-index?= =?UTF-8?q?=20values=20for=20Navigation=20and=20Mask=20layers=20(#11375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ fix: Update z-index values for navigation and mask layers in mobile view - Increased z-index of the .nav-mask class from 63 to 105 for improved layering. - Updated z-index of the nav component from 70 to 110 to ensure it appears above other elements. * šŸ”§ fix: Adjust z-index for navigation component in mobile view - Updated the z-index of the .nav class from 64 to 110 to ensure proper layering above other elements. * šŸ”§ fix: Standardize z-index values across conversation and navigation components - Updated z-index to 125 for various components including ConvoOptions, AccountSettings, BookmarkNav, and FavoriteItem to ensure consistent layering and visibility across the application. --- .../components/Conversations/ConvoOptions/ConvoOptions.tsx | 2 +- client/src/components/Nav/AccountSettings.tsx | 2 +- client/src/components/Nav/Bookmarks/BookmarkNav.tsx | 1 + client/src/components/Nav/Favorites/FavoriteItem.tsx | 1 + client/src/components/Nav/Nav.tsx | 2 +- client/src/mobile.css | 4 ++-- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 14c6b424b4..2ad167a80c 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -294,6 +294,7 @@ function ConvoOptions({ portal={true} menuId={menuId} focusLoop={true} + className="z-[125]" unmountOnHide={true} isOpen={isPopoverActive} setIsOpen={setIsPopoverActive} @@ -321,7 +322,6 @@ function ConvoOptions({ } items={dropdownItems} - className="z-30" /> {showShareDialog && ( = ({ tags, setTags }: BookmarkNavProps) unmountOnHide={true} setIsOpen={setIsMenuOpen} keyPrefix="bookmark-nav-" + className="z-[125]" trigger={ Date: Sat, 17 Jan 2026 16:48:43 -0500 Subject: [PATCH 033/282] =?UTF-8?q?=F0=9F=AB=99=20fix:=20Cache=20Control?= =?UTF-8?q?=20Immutability=20for=20Multi-Agents=20(#11383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ fix: Update @librechat/agents version to 3.0.771 in package.json and package-lock.json * šŸ”§ fix: Update @librechat/agents version to 3.0.772 in package.json and package-lock.json * šŸ”§ fix: Update @librechat/agents version to 3.0.774 in package.json and package-lock.json --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index 9ceb9b624c..f528bcf303 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index d9fd999fc6..066d870e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12646,9 +12646,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.0.77", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.77.tgz", - "integrity": "sha512-Wr9d8bjJAQSl03nEgnAPG6jBQT1fL3sNV3TFDN1FvFQt6WGfdok838Cbcn+/tSGXSPJcICTxNkMT7VN8P6bCPw==", + "version": "3.0.774", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.0.774.tgz", + "integrity": "sha512-Mf2KGhAPnkC+1i5O888Q0WDm1ybcNqZCI6yWBgbIn0EEJiHE3dMRHs9RAcBnR1e+bElRwQxBwXmTfKEtsTQ2ow==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -43129,7 +43129,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index d8a06aad2b..7bb9ec3c03 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -87,7 +87,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.0.77", + "@librechat/agents": "^3.0.774", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.25.2", "@smithy/node-http-handler": "^4.4.5", From 922cdafe81585879f49c5406b7894536b6cab0e4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 17 Jan 2026 17:05:12 -0500 Subject: [PATCH 034/282] =?UTF-8?q?=E2=9C=A8=20v0.8.2-rc3=20(#11384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ chore: Update version to v0.8.2-rc3 across multiple files * šŸ”§ chore: Update package versions for api, client, data-provider, and data-schemas --- Dockerfile | 2 +- Dockerfile.multi | 2 +- api/package.json | 2 +- bun.lock | 8 ++++---- client/jest.config.cjs | 2 +- client/package.json | 2 +- e2e/jestSetup.js | 2 +- helm/librechat/Chart.yaml | 4 ++-- package-lock.json | 16 ++++++++-------- package.json | 2 +- packages/api/package.json | 2 +- packages/client/package.json | 2 +- packages/data-provider/package.json | 2 +- packages/data-provider/src/config.ts | 2 +- packages/data-schemas/package.json | 2 +- 15 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5872440a33..54f84101c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.2-rc2 +# v0.8.2-rc3 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index ca66459a44..2e96f53b46 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.2-rc2 +# v0.8.2-rc3 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 diff --git a/api/package.json b/api/package.json index f528bcf303..f0ae89eb2f 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", diff --git a/bun.lock b/bun.lock index 783bfc762e..daebd2482f 100644 --- a/bun.lock +++ b/bun.lock @@ -254,7 +254,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "devDependencies": { "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", @@ -321,7 +321,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -409,7 +409,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "dependencies": { "axios": "^1.12.1", "dayjs": "^1.11.13", @@ -447,7 +447,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9a9f9f5451..1b7c664ae5 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.2-rc2 */ +/** v0.8.2-rc3 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index 81b2fdf255..d993695050 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "type": "module", "scripts": { diff --git a/e2e/jestSetup.js b/e2e/jestSetup.js index ce2f7a89c2..4023741dc4 100644 --- a/e2e/jestSetup.js +++ b/e2e/jestSetup.js @@ -1,3 +1,3 @@ -// v0.8.2-rc2 +// v0.8.2-rc3 // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './e2e/.env.test' }); diff --git a/helm/librechat/Chart.yaml b/helm/librechat/Chart.yaml index 996d6fc6f9..1791136dfa 100755 --- a/helm/librechat/Chart.yaml +++ b/helm/librechat/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.9.5 +version: 1.9.6 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to @@ -23,7 +23,7 @@ version: 1.9.5 # It is recommended to use it with quotes. # renovate: image=ghcr.io/danny-avila/librechat -appVersion: "v0.8.2-rc2" +appVersion: "v0.8.2-rc3" home: https://www.librechat.ai diff --git a/package-lock.json b/package-lock.json index 066d870e36..bcaee7a84f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "workspaces": [ "api", @@ -45,7 +45,7 @@ }, "api": { "name": "@librechat/backend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.71.0", @@ -442,7 +442,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "license": "ISC", "dependencies": { "@ariakit/react": "^0.4.15", @@ -43087,7 +43087,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -43198,7 +43198,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -45488,7 +45488,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "license": "ISC", "dependencies": { "axios": "^1.12.1", @@ -45546,7 +45546,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/package.json b/package.json index 011551594c..13463acf4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "LibreChat", - "version": "v0.8.2-rc2", + "version": "v0.8.2-rc3", "description": "", "workspaces": [ "api", diff --git a/packages/api/package.json b/packages/api/package.json index 7bb9ec3c03..146e54c1e9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/api", - "version": "1.7.20", + "version": "1.7.21", "type": "commonjs", "description": "MCP services for LibreChat", "main": "dist/index.js", diff --git a/packages/client/package.json b/packages/client/package.json index 9835f62ebc..374b88d352 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/client", - "version": "0.4.4", + "version": "0.4.50", "description": "React components for LibreChat", "repository": { "type": "git", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 63c7adb117..cac429ac96 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.220", + "version": "0.8.230", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ebfcfa93f1..45c964cbd8 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1702,7 +1702,7 @@ export enum TTSProviders { /** Enum for app-wide constants */ export enum Constants { /** Key for the app's version. */ - VERSION = 'v0.8.2-rc2', + VERSION = 'v0.8.2-rc3', /** Key for the Custom Config's version (librechat.yaml). */ CONFIG_VERSION = '1.3.1', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index eb143c0dd6..57de950fbc 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.33", + "version": "0.0.34", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", From 50376171313b9fa9d0936ae187639a47ddab1783 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 18 Jan 2026 11:59:26 -0500 Subject: [PATCH 035/282] =?UTF-8?q?=F0=9F=8E=A8=20fix:=20Layering=20for=20?= =?UTF-8?q?Right-hand=20Side=20Panel=20(#11392)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated the background color in mobile.css for improved visibility. * Refactored class names in SidePanelGroup.tsx to utilize a utility function for better consistency and maintainability. --- client/src/components/SidePanel/SidePanelGroup.tsx | 6 +++--- client/src/mobile.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/SidePanel/SidePanelGroup.tsx b/client/src/components/SidePanel/SidePanelGroup.tsx index 14473127b5..171947cd6b 100644 --- a/client/src/components/SidePanel/SidePanelGroup.tsx +++ b/client/src/components/SidePanel/SidePanelGroup.tsx @@ -6,7 +6,7 @@ import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/c import type { ImperativePanelHandle } from 'react-resizable-panels'; import { useGetStartupConfig } from '~/data-provider'; import ArtifactsPanel from './ArtifactsPanel'; -import { normalizeLayout } from '~/utils'; +import { normalizeLayout, cn } from '~/utils'; import SidePanel from './SidePanel'; import store from '~/store'; @@ -149,9 +149,9 @@ const SidePanelGroup = memo( )} {!hideSidePanel && interfaceConfig.sidePanel === true && (
From 4a1d2b0d941a565ef8514f9abce2218ba5b0f2ff Mon Sep 17 00:00:00 2001 From: Andrei Blizorukov <55080535+ablizorukov@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:32:57 +0100 Subject: [PATCH 040/282] =?UTF-8?q?=F0=9F=93=8A=20fix:=20MeiliSearch=20Syn?= =?UTF-8?q?c=20Threshold=20&=20Document=20Count=20Accuracy=20(#11406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ”§ fix: meilisearch incorrect count of total documents & performance improvement Temporary documents were counted & removed 2 redundant heavy calls to the database, use known information instead šŸ”§ fix: respect MEILI_SYNC_THRESHOLD value Do not sync with meili if threshold was not reached * refactor: reformat lint * fix: forces update if meili index settingsUpdated --- api/db/indexSync.js | 39 ++-- api/db/indexSync.spec.js | 465 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 17 deletions(-) create mode 100644 api/db/indexSync.spec.js diff --git a/api/db/indexSync.js b/api/db/indexSync.js index b39f018b3a..8e8e999d92 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -13,6 +13,11 @@ const searchEnabled = isEnabled(process.env.SEARCH); const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC); let currentTimeout = null; +const defaultSyncThreshold = 1000; +const syncThreshold = process.env.MEILI_SYNC_THRESHOLD + ? parseInt(process.env.MEILI_SYNC_THRESHOLD, 10) + : defaultSyncThreshold; + class MeiliSearchClient { static instance = null; @@ -221,25 +226,25 @@ async function performSync(flowManager, flowId, flowType) { } // Check if we need to sync messages + logger.info('[indexSync] Requesting message sync progress...'); const messageProgress = await Message.getSyncProgress(); if (!messageProgress.isComplete || settingsUpdated) { logger.info( `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, ); - // Check if we should do a full sync or incremental - const messageCount = await Message.countDocuments(); + const messageCount = messageProgress.totalDocuments; const messagesIndexed = messageProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + const unindexedMessages = messageCount - messagesIndexed; - if (messageCount - messagesIndexed > syncThreshold) { - logger.info('[indexSync] Starting full message sync due to large difference'); - await Message.syncWithMeili(); - messagesSync = true; - } else if (messageCount !== messagesIndexed) { - logger.warn('[indexSync] Messages out of sync, performing incremental sync'); + if (settingsUpdated || unindexedMessages > syncThreshold) { + logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`); await Message.syncWithMeili(); messagesSync = true; + } else if (unindexedMessages > 0) { + logger.info( + `[indexSync] ${unindexedMessages} messages unindexed (below threshold: ${syncThreshold}, skipping)`, + ); } } else { logger.info( @@ -254,18 +259,18 @@ async function performSync(flowManager, flowId, flowType) { `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, ); - const convoCount = await Conversation.countDocuments(); + const convoCount = convoProgress.totalDocuments; const convosIndexed = convoProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - if (convoCount - convosIndexed > syncThreshold) { - logger.info('[indexSync] Starting full conversation sync due to large difference'); - await Conversation.syncWithMeili(); - convosSync = true; - } else if (convoCount !== convosIndexed) { - logger.warn('[indexSync] Convos out of sync, performing incremental sync'); + const unindexedConvos = convoCount - convosIndexed; + if (settingsUpdated || unindexedConvos > syncThreshold) { + logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`); await Conversation.syncWithMeili(); convosSync = true; + } else if (unindexedConvos > 0) { + logger.info( + `[indexSync] ${unindexedConvos} convos unindexed (below threshold: ${syncThreshold}, skipping)`, + ); } } else { logger.info( diff --git a/api/db/indexSync.spec.js b/api/db/indexSync.spec.js new file mode 100644 index 0000000000..c2e5901d6a --- /dev/null +++ b/api/db/indexSync.spec.js @@ -0,0 +1,465 @@ +/** + * Unit tests for performSync() function in indexSync.js + * + * Tests use real mongoose with mocked model methods, only mocking external calls. + */ + +const mongoose = require('mongoose'); + +// Mock only external dependencies (not internal classes/models) +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +const mockMeiliHealth = jest.fn(); +const mockMeiliIndex = jest.fn(); +const mockBatchResetMeiliFlags = jest.fn(); +const mockIsEnabled = jest.fn(); +const mockGetLogStores = jest.fn(); + +// Create mock models that will be reused +const createMockModel = (collectionName) => ({ + collection: { name: collectionName }, + getSyncProgress: jest.fn(), + syncWithMeili: jest.fn(), + countDocuments: jest.fn(), +}); + +const originalMessageModel = mongoose.models.Message; +const originalConversationModel = mongoose.models.Conversation; + +// Mock external modules +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('meilisearch', () => ({ + MeiliSearch: jest.fn(() => ({ + health: mockMeiliHealth, + index: mockMeiliIndex, + })), +})); + +jest.mock('./utils', () => ({ + batchResetMeiliFlags: mockBatchResetMeiliFlags, +})); + +jest.mock('@librechat/api', () => ({ + isEnabled: mockIsEnabled, + FlowStateManager: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: mockGetLogStores, +})); + +// Set environment before module load +process.env.MEILI_HOST = 'http://localhost:7700'; +process.env.MEILI_MASTER_KEY = 'test-key'; +process.env.SEARCH = 'true'; +process.env.MEILI_SYNC_THRESHOLD = '1000'; // Set threshold before module loads + +describe('performSync() - syncThreshold logic', () => { + const ORIGINAL_ENV = process.env; + let Message; + let Conversation; + + beforeAll(() => { + Message = createMockModel('messages'); + Conversation = createMockModel('conversations'); + + mongoose.models.Message = Message; + mongoose.models.Conversation = Conversation; + }); + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + // Reset modules to ensure fresh load of indexSync.js and its top-level consts (like syncThreshold) + jest.resetModules(); + + // Set up environment + process.env = { ...ORIGINAL_ENV }; + process.env.MEILI_HOST = 'http://localhost:7700'; + process.env.MEILI_MASTER_KEY = 'test-key'; + process.env.SEARCH = 'true'; + delete process.env.MEILI_NO_SYNC; + + // Re-ensure models are available in mongoose after resetModules + // We must require mongoose again to get the fresh instance that indexSync will use + const mongoose = require('mongoose'); + mongoose.models.Message = Message; + mongoose.models.Conversation = Conversation; + + // Mock isEnabled + mockIsEnabled.mockImplementation((val) => val === 'true' || val === true); + + // Mock MeiliSearch client responses + mockMeiliHealth.mockResolvedValue({ status: 'available' }); + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: ['user'] }), + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + mockBatchResetMeiliFlags.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + afterAll(() => { + mongoose.models.Message = originalMessageModel; + mongoose.models.Conversation = originalConversationModel; + }); + + test('triggers sync when unindexed messages exceed syncThreshold', async () => { + // Arrange: Set threshold before module load + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Arrange: 1050 unindexed messages > 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1150, // 1050 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync triggered because 1050 > 1000 + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1150 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (1050 unindexed)', + ); + + // Assert: Conversation sync NOT triggered (already complete) + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + }); + + test('skips sync when unindexed messages are below syncThreshold', async () => { + // Arrange: 50 unindexed messages < 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync NOT triggered because 50 < 1000 + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/150 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] 50 messages unindexed (below threshold: 1000, skipping)', + ); + + // Assert: Conversation sync NOT triggered (already complete) + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + }); + + test('respects syncThreshold at boundary (exactly at threshold)', async () => { + // Arrange: 1000 unindexed messages = 1000 threshold (NOT greater than) + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1100, // 1000 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 0, + totalDocuments: 0, + isComplete: true, + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync NOT triggered because 1000 is NOT > 1000 + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1100 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] 1000 messages unindexed (below threshold: 1000, skipping)', + ); + }); + + test('triggers sync when unindexed is threshold + 1', async () => { + // Arrange: 1001 unindexed messages > 1000 threshold + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 1101, // 1001 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 0, + totalDocuments: 0, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + + // Assert: Message sync triggered because 1001 > 1000 + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Messages need syncing: 100/1101 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (1001 unindexed)', + ); + }); + + test('uses totalDocuments from convoProgress for conversation sync decisions', async () => { + // Arrange: Messages complete, conversations need sync + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 1100, // 1050 unindexed > 1000 threshold + isComplete: false, + }); + + Conversation.syncWithMeili.mockResolvedValue(undefined); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls (the optimization) + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: Only conversation sync triggered + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Conversations need syncing: 50/1100 indexed', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting convos sync (1050 unindexed)', + ); + }); + + test('skips sync when collections are fully synced', async () => { + // Arrange: Everything already synced + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: No countDocuments calls + expect(Message.countDocuments).not.toHaveBeenCalled(); + expect(Conversation.countDocuments).not.toHaveBeenCalled(); + + // Assert: No sync triggered + expect(Message.syncWithMeili).not.toHaveBeenCalled(); + expect(Conversation.syncWithMeili).not.toHaveBeenCalled(); + + // Assert: Correct logs + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Messages are fully synced: 100/100'); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Conversations are fully synced: 50/50', + ); + }); + + test('triggers message sync when settingsUpdated even if below syncThreshold', async () => { + // Arrange: Only 50 unindexed messages (< 1000 threshold), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 50, + isComplete: true, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Message sync triggered despite being below threshold (50 < 1000) + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (50 unindexed)', + ); + }); + + test('triggers conversation sync when settingsUpdated even if below syncThreshold', async () => { + // Arrange: Messages complete, conversations have 50 unindexed (< 1000 threshold), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 100, + isComplete: true, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 100, // 50 unindexed + isComplete: false, + }); + + Conversation.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Conversation sync triggered despite being below threshold (50 < 1000) + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)'); + }); + + test('triggers both message and conversation sync when settingsUpdated even if both below syncThreshold', async () => { + // Arrange: Set threshold before module load + process.env.MEILI_SYNC_THRESHOLD = '1000'; + + // Arrange: Both have documents below threshold (50 each), but settings were updated + Message.getSyncProgress.mockResolvedValue({ + totalProcessed: 100, + totalDocuments: 150, // 50 unindexed + isComplete: false, + }); + + Conversation.getSyncProgress.mockResolvedValue({ + totalProcessed: 50, + totalDocuments: 100, // 50 unindexed + isComplete: false, + }); + + Message.syncWithMeili.mockResolvedValue(undefined); + Conversation.syncWithMeili.mockResolvedValue(undefined); + + // Mock settings update scenario + mockMeiliIndex.mockReturnValue({ + getSettings: jest.fn().mockResolvedValue({ filterableAttributes: [] }), // No user field + updateSettings: jest.fn().mockResolvedValue({}), + search: jest.fn().mockResolvedValue({ hits: [] }), + }); + + // Act + const indexSync = require('./indexSync'); + await indexSync(); + + // Assert: Flags were reset due to settings update + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Message.collection); + expect(mockBatchResetMeiliFlags).toHaveBeenCalledWith(Conversation.collection); + + // Assert: Both syncs triggered despite both being below threshold + expect(Message.syncWithMeili).toHaveBeenCalledTimes(1); + expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + '[indexSync] Starting message sync (50 unindexed)', + ); + expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)'); + }); +}); From e509ba5be046736307536c695c5d2c1701d5eb77 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Jan 2026 08:45:43 -0500 Subject: [PATCH 041/282] =?UTF-8?q?=F0=9F=AA=84=20fix:=20Code=20Block=20ha?= =?UTF-8?q?ndling=20in=20Artifact=20Updates=20(#11417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved detection of code blocks to support both language identifiers and plain code fences. * Updated tests to cover various scenarios, including edge cases with different language identifiers and multiline content. * Ensured proper handling of code blocks with trailing whitespace and complex syntax. --- api/server/services/Artifacts/update.js | 18 +- api/server/services/Artifacts/update.spec.js | 263 +++++++++++++++++++ 2 files changed, 277 insertions(+), 4 deletions(-) diff --git a/api/server/services/Artifacts/update.js b/api/server/services/Artifacts/update.js index d068593f8c..be1644b11c 100644 --- a/api/server/services/Artifacts/update.js +++ b/api/server/services/Artifacts/update.js @@ -73,15 +73,25 @@ const replaceArtifactContent = (originalText, artifact, original, updated) => { return null; } - // Check if there are code blocks - const codeBlockStart = artifactContent.indexOf('```\n', contentStart); + // Check if there are code blocks - handle both ```\n and ```lang\n formats + let codeBlockStart = artifactContent.indexOf('```', contentStart); const codeBlockEnd = artifactContent.lastIndexOf('\n```', contentEnd); + // If we found opening backticks, find the actual newline (skipping any language identifier) + if (codeBlockStart !== -1) { + const newlineAfterBackticks = artifactContent.indexOf('\n', codeBlockStart); + if (newlineAfterBackticks !== -1 && newlineAfterBackticks < contentEnd) { + codeBlockStart = newlineAfterBackticks; + } else { + codeBlockStart = -1; + } + } + // Determine where to look for the original content let searchStart, searchEnd; if (codeBlockStart !== -1) { - // Code block starts - searchStart = codeBlockStart + 4; // after ```\n + // Code block starts - searchStart is right after the newline following ```[lang] + searchStart = codeBlockStart + 1; // after the newline if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) { // Code block has proper ending diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js index 2a3e0bbe39..39a4f02863 100644 --- a/api/server/services/Artifacts/update.spec.js +++ b/api/server/services/Artifacts/update.spec.js @@ -494,5 +494,268 @@ ${original}`; /```\n {2}function test\(\) \{\n {4}return \{\n {6}value: 100\n {4}\};\n {2}\}\n```/, ); }); + + test('should handle code blocks with language identifiers (```svg, ```html, etc.)', () => { + const svgContent = ` + + +`; + + /** Artifact with language identifier in code block */ + const artifactText = `${ARTIFACT_START}{identifier="test-svg" type="image/svg+xml" title="Test SVG"} +\`\`\`svg +${svgContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + expect(artifacts).toHaveLength(1); + + const updatedSvg = svgContent.replace('#FFFFFF', '#131313'); + const result = replaceArtifactContent(artifactText, artifacts[0], svgContent, updatedSvg); + + expect(result).not.toBeNull(); + expect(result).toContain('#131313'); + expect(result).not.toContain('#FFFFFF'); + expect(result).toMatch(/```svg\n/); + }); + + test('should handle code blocks with complex language identifiers', () => { + const htmlContent = ` + +Test +Hello +`; + + const artifactText = `${ARTIFACT_START}{identifier="test-html" type="text/html" title="Test HTML"} +\`\`\`html +${htmlContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updatedHtml = htmlContent.replace('Hello', 'Updated'); + const result = replaceArtifactContent(artifactText, artifacts[0], htmlContent, updatedHtml); + + expect(result).not.toBeNull(); + expect(result).toContain('Updated'); + expect(result).toMatch(/```html\n/); + }); + }); + + describe('code block edge cases', () => { + test('should handle code block without language identifier (```\\n)', () => { + const content = 'const x = 1;\nconst y = 2;'; + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\` +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent(artifactText, artifacts[0], content, 'updated'); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + expect(result).toMatch(/```\nupdated\n```/); + }); + + test('should handle various language identifiers', () => { + const languages = [ + 'javascript', + 'typescript', + 'python', + 'jsx', + 'tsx', + 'css', + 'json', + 'xml', + 'markdown', + 'md', + ]; + + for (const lang of languages) { + const content = `test content for ${lang}`; + const artifactText = `${ARTIFACT_START}{identifier="test-${lang}" type="text/plain" title="Test"} +\`\`\`${lang} +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + expect(artifacts).toHaveLength(1); + + const result = replaceArtifactContent(artifactText, artifacts[0], content, 'updated'); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + expect(result).toMatch(new RegExp(`\`\`\`${lang}\\n`)); + } + }); + + test('should handle single character language identifier', () => { + const content = 'single char lang'; + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\`r +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent(artifactText, artifacts[0], content, 'updated'); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + expect(result).toMatch(/```r\n/); + }); + + test('should handle code block with content that looks like code fence', () => { + const content = 'Line 1\nSome text with ``` backticks in middle\nLine 3'; + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\`text +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent(artifactText, artifacts[0], content, 'updated'); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + }); + + test('should handle code block with trailing whitespace in language line', () => { + const content = 'whitespace test'; + /** Note: trailing spaces after 'python' */ + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\`python +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent(artifactText, artifacts[0], content, 'updated'); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + }); + + test('should handle react/jsx content with complex syntax', () => { + const jsxContent = `function App() { + const [count, setCount] = useState(0); + return ( +
+

Count: {count}

+ +
+ ); +}`; + + const artifactText = `${ARTIFACT_START}{identifier="react-app" type="application/vnd.react" title="React App"} +\`\`\`jsx +${jsxContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updatedJsx = jsxContent.replace('Increment', 'Click me'); + const result = replaceArtifactContent(artifactText, artifacts[0], jsxContent, updatedJsx); + + expect(result).not.toBeNull(); + expect(result).toContain('Click me'); + expect(result).not.toContain('Increment'); + expect(result).toMatch(/```jsx\n/); + }); + + test('should handle mermaid diagram content', () => { + const mermaidContent = `graph TD + A[Start] --> B{Is it?} + B -->|Yes| C[OK] + B -->|No| D[End]`; + + const artifactText = `${ARTIFACT_START}{identifier="diagram" type="application/vnd.mermaid" title="Flow"} +\`\`\`mermaid +${mermaidContent} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updatedMermaid = mermaidContent.replace('Start', 'Begin'); + const result = replaceArtifactContent( + artifactText, + artifacts[0], + mermaidContent, + updatedMermaid, + ); + + expect(result).not.toBeNull(); + expect(result).toContain('Begin'); + expect(result).toMatch(/```mermaid\n/); + }); + + test('should handle artifact without code block (plain text)', () => { + const content = 'Just plain text without code fences'; + const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"} +${content} +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent( + artifactText, + artifacts[0], + content, + 'updated plain text', + ); + + expect(result).not.toBeNull(); + expect(result).toContain('updated plain text'); + expect(result).not.toContain('```'); + }); + + test('should handle multiline content with various newline patterns', () => { + const content = `Line 1 +Line 2 + +Line 4 after empty line + Indented line + Double indented`; + + const artifactText = `${ARTIFACT_START}{identifier="test" type="text/plain" title="Test"} +\`\`\` +${content} +\`\`\` +${ARTIFACT_END}`; + + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const updated = content.replace('Line 1', 'First Line'); + const result = replaceArtifactContent(artifactText, artifacts[0], content, updated); + + expect(result).not.toBeNull(); + expect(result).toContain('First Line'); + expect(result).toContain(' Indented line'); + expect(result).toContain(' Double indented'); + }); }); }); From 32e6f3b8e50edd0cf3cdbbdd50927f588c4a827d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:41:28 -0800 Subject: [PATCH 042/282] =?UTF-8?q?=F0=9F=93=A2=20fix:=20Alert=20for=20Age?= =?UTF-8?q?nt=20Builder=20Name=20Invalidation=20(#11430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/SidePanel/Agents/AgentConfig.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 2e247a00f0..a81ef780a9 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -209,6 +209,7 @@ export default function AgentConfig() { 'mt-1 w-56 text-sm text-red-500', errors.name ? 'visible h-auto' : 'invisible h-0', )} + role="alert" > {errors.name ? errors.name.message : ' '}
From 36c5a88c4eca0a489cfd42bdf489a39d4dceb19d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Jan 2026 14:43:19 -0500 Subject: [PATCH 043/282] =?UTF-8?q?=F0=9F=92=B0=20fix:=20Multi-Agent=20Tok?= =?UTF-8?q?en=20Spending=20&=20Prevent=20Double-Spend=20(#11433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Token Spending Logic for Multi-Agents on Abort Scenarios * Implemented logic to skip token spending if a conversation is aborted, preventing double-spending. * Introduced `spendCollectedUsage` function to handle token spending for multiple models during aborts, ensuring accurate accounting for parallel agents. * Updated `GenerationJobManager` to store and retrieve collected usage data for improved abort handling. * Added comprehensive tests for the new functionality, covering various scenarios including cache token handling and parallel agent usage. * fix: Memory Context Handling for Multi-Agents * Refactored `buildMessages` method to pass memory context to parallel agents, ensuring they share the same user context. * Improved handling of memory context when no existing instructions are present for parallel agents. * Added comprehensive tests to verify memory context propagation and behavior under various scenarios, including cases with no memory available and empty agent configurations. * Enhanced logging for better traceability of memory context additions to agents. * chore: Memory Context Documentation for Parallel Agents * Updated documentation in the `AgentClient` class to clarify the in-place mutation of agentConfig objects when passing memory context to parallel agents. * Added notes on the implications of mutating objects directly to ensure all parallel agents receive the correct memory context before execution. * chore: UsageMetadata Interface docs for Token Spending * Expanded the UsageMetadata interface to support both OpenAI and Anthropic cache token formats. * Added detailed documentation for cache token properties, including mutually exclusive fields for different model types. * Improved clarity on how to access cache token details for accurate token spending tracking. * fix: Enhance Token Spending Logic in Abort Middleware * Refactored `spendCollectedUsage` function to utilize Promise.all for concurrent token spending, improving performance and ensuring all operations complete before clearing the collectedUsage array. * Added documentation to clarify the importance of clearing the collectedUsage array to prevent double-spending in abort scenarios. * Updated tests to verify the correct behavior of the spending logic and the clearing of the array after spending operations. --- api/server/controllers/agents/client.js | 45 +- api/server/controllers/agents/client.test.js | 220 ++++++++ api/server/middleware/abortMiddleware.js | 100 +++- api/server/middleware/abortMiddleware.spec.js | 428 ++++++++++++++++ .../services/Endpoints/agents/initialize.js | 9 +- .../api/src/stream/GenerationJobManager.ts | 41 +- .../stream/__tests__/collectedUsage.spec.ts | 482 ++++++++++++++++++ .../implementations/InMemoryJobStore.ts | 31 +- .../stream/implementations/RedisJobStore.ts | 38 +- packages/api/src/stream/index.ts | 5 +- .../api/src/stream/interfaces/IJobStore.ts | 69 +++ 11 files changed, 1440 insertions(+), 28 deletions(-) create mode 100644 api/server/middleware/abortMiddleware.spec.js create mode 100644 packages/api/src/stream/__tests__/collectedUsage.spec.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 2b5872411b..5f3618de4c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -522,14 +522,36 @@ class AgentClient extends BaseClient { } const withoutKeys = await this.useMemory(); - if (withoutKeys) { - systemContent += `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; + const memoryContext = withoutKeys + ? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}` + : ''; + if (memoryContext) { + systemContent += memoryContext; } if (systemContent) { this.options.agent.instructions = systemContent; } + /** + * Pass memory context to parallel agents (addedConvo) so they have the same user context. + * + * NOTE: This intentionally mutates the agentConfig objects in place. The agentConfigs Map + * holds references to config objects that will be passed to the graph runtime. Mutating + * them here ensures all parallel agents receive the memory context before execution starts. + * Creating new objects would not work because the Map references would still point to the old objects. + */ + if (memoryContext && this.agentConfigs?.size > 0) { + for (const [agentId, agentConfig] of this.agentConfigs.entries()) { + if (agentConfig.instructions) { + agentConfig.instructions = agentConfig.instructions + '\n\n' + memoryContext; + } else { + agentConfig.instructions = memoryContext; + } + logger.debug(`[AgentClient] Added memory context to parallel agent: ${agentId}`); + } + } + return result; } @@ -1084,11 +1106,20 @@ class AgentClient extends BaseClient { this.artifactPromises.push(...attachments); } - await this.recordCollectedUsage({ - context: 'message', - balance: balanceConfig, - transactions: transactionsConfig, - }); + /** Skip token spending if aborted - the abort handler (abortMiddleware.js) handles it + This prevents double-spending when user aborts via `/api/agents/chat/abort` */ + const wasAborted = abortController?.signal?.aborted; + if (!wasAborted) { + await this.recordCollectedUsage({ + context: 'message', + balance: balanceConfig, + transactions: transactionsConfig, + }); + } else { + logger.debug( + '[api/server/controllers/agents/client.js #chatCompletion] Skipping token spending - handled by abort middleware', + ); + } } catch (err) { logger.error( '[api/server/controllers/agents/client.js #chatCompletion] Error in cleanup phase', diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 14f0df9bb0..402d011fd6 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -1849,4 +1849,224 @@ describe('AgentClient - titleConvo', () => { }); }); }); + + describe('buildMessages - memory context for parallel agents', () => { + let client; + let mockReq; + let mockRes; + let mockAgent; + let mockOptions; + + beforeEach(() => { + jest.clearAllMocks(); + + mockAgent = { + id: 'primary-agent', + name: 'Primary Agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + instructions: 'Primary agent instructions', + model_parameters: { + model: 'gpt-4', + }, + tools: [], + }; + + mockReq = { + user: { + id: 'user-123', + personalization: { + memories: true, + }, + }, + body: { + endpoint: EModelEndpoint.openAI, + }, + config: { + memory: { + disabled: false, + }, + }, + }; + + mockRes = {}; + + mockOptions = { + req: mockReq, + res: mockRes, + agent: mockAgent, + endpoint: EModelEndpoint.agents, + }; + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + client.shouldSummarize = false; + client.maxContextTokens = 4096; + }); + + it('should pass memory context to parallel agents (addedConvo)', async () => { + const memoryContent = 'User prefers dark mode. User is a software developer.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + const parallelAgent1 = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Parallel agent 1 instructions', + provider: EModelEndpoint.openAI, + }; + + const parallelAgent2 = { + id: 'parallel-agent-2', + name: 'Parallel Agent 2', + instructions: 'Parallel agent 2 instructions', + provider: EModelEndpoint.anthropic, + }; + + client.agentConfigs = new Map([ + ['parallel-agent-1', parallelAgent1], + ['parallel-agent-2', parallelAgent2], + ]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(client.useMemory).toHaveBeenCalled(); + + expect(client.options.agent.instructions).toContain('Base instructions'); + expect(client.options.agent.instructions).toContain(memoryContent); + + expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions'); + expect(parallelAgent1.instructions).toContain(memoryContent); + + expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions'); + expect(parallelAgent2.instructions).toContain(memoryContent); + }); + + it('should not modify parallel agents when no memory context is available', async () => { + client.useMemory = jest.fn().mockResolvedValue(undefined); + + const parallelAgent = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Original parallel instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(parallelAgent.instructions).toBe('Original parallel instructions'); + }); + + it('should handle parallel agents without existing instructions', async () => { + const memoryContent = 'User is a data scientist.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + const parallelAgentNoInstructions = { + id: 'parallel-agent-no-instructions', + name: 'Parallel Agent No Instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([ + ['parallel-agent-no-instructions', parallelAgentNoInstructions], + ]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: null, + additional_instructions: null, + }); + + expect(parallelAgentNoInstructions.instructions).toContain(memoryContent); + }); + + it('should not modify agentConfigs when none exist', async () => { + const memoryContent = 'User prefers concise responses.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + client.agentConfigs = null; + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await expect( + client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }), + ).resolves.not.toThrow(); + + expect(client.options.agent.instructions).toContain(memoryContent); + }); + + it('should handle empty agentConfigs map', async () => { + const memoryContent = 'User likes detailed explanations.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + + client.agentConfigs = new Map(); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await expect( + client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }), + ).resolves.not.toThrow(); + + expect(client.options.agent.instructions).toContain(memoryContent); + }); + }); }); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index b85f1439cc..d07a09682d 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -7,13 +7,89 @@ const { sanitizeMessageForTransmit, } = require('@librechat/api'); const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider'); +const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const clearPendingReq = require('~/cache/clearPendingReq'); const { sendError } = require('~/server/middleware/error'); -const { spendTokens } = require('~/models/spendTokens'); const { saveMessage, getConvo } = require('~/models'); const { abortRun } = require('./abortRun'); +/** + * Spend tokens for all models from collected usage. + * This handles both sequential and parallel agent execution. + * + * IMPORTANT: After spending, this function clears the collectedUsage array + * to prevent double-spending. The array is shared with AgentClient.collectedUsage, + * so clearing it here prevents the finally block from also spending tokens. + * + * @param {Object} params + * @param {string} params.userId - User ID + * @param {string} params.conversationId - Conversation ID + * @param {Array} params.collectedUsage - Usage metadata from all models + * @param {string} [params.fallbackModel] - Fallback model name if not in usage + */ +async function spendCollectedUsage({ userId, conversationId, collectedUsage, fallbackModel }) { + if (!collectedUsage || collectedUsage.length === 0) { + return; + } + + const spendPromises = []; + + for (const usage of collectedUsage) { + if (!usage) { + continue; + } + + // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) + const cache_creation = + Number(usage.input_token_details?.cache_creation) || + Number(usage.cache_creation_input_tokens) || + 0; + const cache_read = + Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0; + + const txMetadata = { + context: 'abort', + conversationId, + user: userId, + model: usage.model ?? fallbackModel, + }; + + if (cache_creation > 0 || cache_read > 0) { + spendPromises.push( + spendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }).catch((err) => { + logger.error('[abortMiddleware] Error spending structured tokens for abort', err); + }), + ); + continue; + } + + spendPromises.push( + spendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }).catch((err) => { + logger.error('[abortMiddleware] Error spending tokens for abort', err); + }), + ); + } + + // Wait for all token spending to complete + await Promise.all(spendPromises); + + // Clear the array to prevent double-spending from the AgentClient finally block. + // The collectedUsage array is shared by reference with AgentClient.collectedUsage, + // so clearing it here ensures recordCollectedUsage() sees an empty array and returns early. + collectedUsage.length = 0; +} + /** * Abort an active message generation. * Uses GenerationJobManager for all agent requests. @@ -39,9 +115,8 @@ async function abortMessage(req, res) { return; } - const { jobData, content, text } = abortResult; + const { jobData, content, text, collectedUsage } = abortResult; - // Count tokens and spend them const completionTokens = await countTokens(text); const promptTokens = jobData?.promptTokens ?? 0; @@ -62,10 +137,21 @@ async function abortMessage(req, res) { tokenCount: completionTokens, }; - await spendTokens( - { ...responseMessage, context: 'incomplete', user: userId }, - { promptTokens, completionTokens }, - ); + // Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo) + if (collectedUsage && collectedUsage.length > 0) { + await spendCollectedUsage({ + userId, + conversationId: jobData?.conversationId, + collectedUsage, + fallbackModel: jobData?.model, + }); + } else { + // Fallback: no collected usage, use text-based token counting for primary model only + await spendTokens( + { ...responseMessage, context: 'incomplete', user: userId }, + { promptTokens, completionTokens }, + ); + } await saveMessage( req, diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js new file mode 100644 index 0000000000..93f2ce558b --- /dev/null +++ b/api/server/middleware/abortMiddleware.spec.js @@ -0,0 +1,428 @@ +/** + * Tests for abortMiddleware - spendCollectedUsage function + * + * This tests the token spending logic for abort scenarios, + * particularly for parallel agents (addedConvo) where multiple + * models need their tokens spent. + */ + +const mockSpendTokens = jest.fn().mockResolvedValue(); +const mockSpendStructuredTokens = jest.fn().mockResolvedValue(); + +jest.mock('~/models/spendTokens', () => ({ + spendTokens: (...args) => mockSpendTokens(...args), + spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +jest.mock('@librechat/api', () => ({ + countTokens: jest.fn().mockResolvedValue(100), + isEnabled: jest.fn().mockReturnValue(false), + sendEvent: jest.fn(), + GenerationJobManager: { + abortJob: jest.fn(), + }, + sanitizeMessageForTransmit: jest.fn((msg) => msg), +})); + +jest.mock('librechat-data-provider', () => ({ + isAssistantsEndpoint: jest.fn().mockReturnValue(false), + ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' }, +})); + +jest.mock('~/app/clients/prompts', () => ({ + truncateText: jest.fn((text) => text), + smartTruncateText: jest.fn((text) => text), +})); + +jest.mock('~/cache/clearPendingReq', () => jest.fn().mockResolvedValue()); + +jest.mock('~/server/middleware/error', () => ({ + sendError: jest.fn(), +})); + +jest.mock('~/models', () => ({ + saveMessage: jest.fn().mockResolvedValue(), + getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }), +})); + +jest.mock('./abortRun', () => ({ + abortRun: jest.fn(), +})); + +// Import the module after mocks are set up +// We need to extract the spendCollectedUsage function for testing +// Since it's not exported, we'll test it through the handleAbort flow + +describe('abortMiddleware - spendCollectedUsage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('spendCollectedUsage logic', () => { + // Since spendCollectedUsage is not exported, we test the logic directly + // by replicating the function here for unit testing + + const spendCollectedUsage = async ({ + userId, + conversationId, + collectedUsage, + fallbackModel, + }) => { + if (!collectedUsage || collectedUsage.length === 0) { + return; + } + + const spendPromises = []; + + for (const usage of collectedUsage) { + if (!usage) { + continue; + } + + const cache_creation = + Number(usage.input_token_details?.cache_creation) || + Number(usage.cache_creation_input_tokens) || + 0; + const cache_read = + Number(usage.input_token_details?.cache_read) || + Number(usage.cache_read_input_tokens) || + 0; + + const txMetadata = { + context: 'abort', + conversationId, + user: userId, + model: usage.model ?? fallbackModel, + }; + + if (cache_creation > 0 || cache_read > 0) { + spendPromises.push( + mockSpendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }).catch(() => { + // Log error but don't throw + }), + ); + continue; + } + + spendPromises.push( + mockSpendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }).catch(() => { + // Log error but don't throw + }), + ); + } + + // Wait for all token spending to complete + await Promise.all(spendPromises); + + // Clear the array to prevent double-spending + collectedUsage.length = 0; + }; + + it('should return early if collectedUsage is empty', async () => { + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage: [], + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + + it('should return early if collectedUsage is null', async () => { + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage: null, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + + it('should skip null entries in collectedUsage', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + null, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + }); + + it('should spend tokens for single model', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ + context: 'abort', + conversationId: 'convo-123', + user: 'user-123', + model: 'gpt-4', + }), + { promptTokens: 100, completionTokens: 50 }, + ); + }); + + it('should spend tokens for multiple models (parallel agents)', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + { input_tokens: 120, output_tokens: 60, model: 'gemini-pro' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(3); + + // Verify each model was called + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'gpt-4' }), + { promptTokens: 100, completionTokens: 50 }, + ); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: 'claude-3' }), + { promptTokens: 80, completionTokens: 40 }, + ); + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ model: 'gemini-pro' }), + { promptTokens: 120, completionTokens: 60 }, + ); + }); + + it('should use fallbackModel when usage.model is missing', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'fallback-model', + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'fallback-model' }), + expect.any(Object), + ); + }); + + it('should use spendStructuredTokens for OpenAI format cache tokens', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { + cache_creation: 20, + cache_read: 10, + }, + }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'gpt-4', context: 'abort' }), + { + promptTokens: { + input: 100, + write: 20, + read: 10, + }, + completionTokens: 50, + }, + ); + }); + + it('should use spendStructuredTokens for Anthropic format cache tokens', async () => { + const collectedUsage = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'claude-3', + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ model: 'claude-3' }), + { + promptTokens: { + input: 100, + write: 25, + read: 15, + }, + completionTokens: 50, + }, + ); + }); + + it('should handle mixed cache and non-cache entries', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'claude-3', + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + { input_tokens: 200, output_tokens: 20, model: 'gemini-pro' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + }); + + it('should handle real-world parallel agent abort scenario', async () => { + // Simulates: Primary agent (gemini) + addedConvo agent (gpt-5) aborted mid-stream + const collectedUsage = [ + { input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' }, + { input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gemini-3-flash-preview', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + + // Primary model + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ model: 'gemini-3-flash-preview' }), + { promptTokens: 31596, completionTokens: 151 }, + ); + + // Parallel model (addedConvo) + expect(mockSpendTokens).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ model: 'gpt-5.2' }), + { promptTokens: 28000, completionTokens: 120 }, + ); + }); + + it('should clear collectedUsage array after spending to prevent double-spending', async () => { + // This tests the race condition fix: after abort middleware spends tokens, + // the collectedUsage array is cleared so AgentClient.recordCollectedUsage() + // (which shares the same array reference) sees an empty array and returns early. + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + expect(collectedUsage.length).toBe(2); + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + + // The array should be cleared after spending + expect(collectedUsage.length).toBe(0); + }); + + it('should await all token spending operations before clearing array', async () => { + // Ensure we don't clear the array before spending completes + let spendCallCount = 0; + mockSpendTokens.mockImplementation(async () => { + spendCallCount++; + // Simulate async delay + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + await spendCollectedUsage({ + userId: 'user-123', + conversationId: 'convo-123', + collectedUsage, + fallbackModel: 'gpt-4', + }); + + // Both spend calls should have completed + expect(spendCallCount).toBe(2); + + // Array should be cleared after awaiting + expect(collectedUsage.length).toBe(0); + }); + }); +}); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 626beed153..a691480119 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -3,10 +3,11 @@ const { createContentAggregator } = require('@librechat/agents'); const { initializeAgent, validateAgentModel, - getCustomEndpointConfig, - createSequentialChainEdges, createEdgeCollector, filterOrphanedEdges, + GenerationJobManager, + getCustomEndpointConfig, + createSequentialChainEdges, } = require('@librechat/api'); const { EModelEndpoint, @@ -314,6 +315,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, }); + if (streamId) { + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + } + return { client, userMCPAuthMap }; }; diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 13544fc445..26c2ef73a6 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -1,9 +1,11 @@ import { logger } from '@librechat/data-schemas'; import type { StandardGraph } from '@librechat/agents'; -import type { Agents } from 'librechat-data-provider'; +import { parseTextParts } from 'librechat-data-provider'; +import type { Agents, TMessageContentParts } from 'librechat-data-provider'; import type { SerializableJobData, IEventTransport, + UsageMetadata, AbortResult, IJobStore, } from './interfaces/IJobStore'; @@ -585,7 +587,14 @@ class GenerationJobManagerClass { if (!jobData) { logger.warn(`[GenerationJobManager] Cannot abort - job not found: ${streamId}`); - return { success: false, jobData: null, content: [], finalEvent: null }; + return { + text: '', + content: [], + jobData: null, + success: false, + finalEvent: null, + collectedUsage: [], + }; } // Emit abort signal for cross-replica support (Redis mode) @@ -599,15 +608,21 @@ class GenerationJobManagerClass { runtime.abortController.abort(); } - // Get content before clearing state + /** Content before clearing state */ const result = await this.jobStore.getContentParts(streamId); const content = result?.content ?? []; - // Detect "early abort" - aborted before any generation happened (e.g., during tool loading) - // In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation + /** Collected usage for all models */ + const collectedUsage = this.jobStore.getCollectedUsage(streamId); + + /** Text from content parts for fallback token counting */ + const text = parseTextParts(content as TMessageContentParts[]); + + /** Detect "early abort" - aborted before any generation happened (e.g., during tool loading) + In this case, no messages were saved to DB, so frontend shouldn't navigate to conversation */ const isEarlyAbort = content.length === 0 && !jobData.responseMessageId; - // Create final event for abort + /** Final event for abort */ const userMessageId = jobData.userMessage?.messageId; const abortFinalEvent: t.ServerSentEvent = { @@ -669,6 +684,8 @@ class GenerationJobManagerClass { jobData, content, finalEvent: abortFinalEvent, + text, + collectedUsage, }; } @@ -933,6 +950,18 @@ class GenerationJobManagerClass { this.jobStore.setContentParts(streamId, contentParts); } + /** + * Set reference to the collectedUsage array. + * This array accumulates token usage from all models during generation. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + // Use runtime state check for performance (sync check) + if (!this.runtimeState.has(streamId)) { + return; + } + this.jobStore.setCollectedUsage(streamId, collectedUsage); + } + /** * Set reference to the graph instance. */ diff --git a/packages/api/src/stream/__tests__/collectedUsage.spec.ts b/packages/api/src/stream/__tests__/collectedUsage.spec.ts new file mode 100644 index 0000000000..3e534b537a --- /dev/null +++ b/packages/api/src/stream/__tests__/collectedUsage.spec.ts @@ -0,0 +1,482 @@ +/** + * Tests for collected usage functionality in GenerationJobManager. + * + * This tests the storage and retrieval of collectedUsage for abort handling, + * ensuring all models (including parallel agents from addedConvo) have their + * tokens spent when a conversation is aborted. + */ + +import type { UsageMetadata } from '../interfaces/IJobStore'; + +describe('CollectedUsage - InMemoryJobStore', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should store and retrieve collectedUsage', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-1'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + store.setCollectedUsage(streamId, collectedUsage); + const retrieved = store.getCollectedUsage(streamId); + + expect(retrieved).toEqual(collectedUsage); + expect(retrieved).toHaveLength(2); + + await store.destroy(); + }); + + it('should return empty array when no collectedUsage set', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-2'; + await store.createJob(streamId, 'user-1'); + + const retrieved = store.getCollectedUsage(streamId); + + expect(retrieved).toEqual([]); + + await store.destroy(); + }); + + it('should return empty array for non-existent stream', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const retrieved = store.getCollectedUsage('non-existent-stream'); + + expect(retrieved).toEqual([]); + + await store.destroy(); + }); + + it('should update collectedUsage when set multiple times', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-3'; + await store.createJob(streamId, 'user-1'); + + const usage1: UsageMetadata[] = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + store.setCollectedUsage(streamId, usage1); + + // Simulate more usage being added + const usage2: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + store.setCollectedUsage(streamId, usage2); + + const retrieved = store.getCollectedUsage(streamId); + expect(retrieved).toHaveLength(2); + + await store.destroy(); + }); + + it('should clear collectedUsage when clearContentState is called', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-4'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + store.setCollectedUsage(streamId, collectedUsage); + + expect(store.getCollectedUsage(streamId)).toHaveLength(1); + + store.clearContentState(streamId); + + expect(store.getCollectedUsage(streamId)).toEqual([]); + + await store.destroy(); + }); + + it('should clear collectedUsage when job is deleted', async () => { + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const store = new InMemoryJobStore(); + await store.initialize(); + + const streamId = 'test-stream-5'; + await store.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + store.setCollectedUsage(streamId, collectedUsage); + + await store.deleteJob(streamId); + + expect(store.getCollectedUsage(streamId)).toEqual([]); + + await store.destroy(); + }); +}); + +describe('CollectedUsage - GenerationJobManager', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should set and retrieve collectedUsage through manager', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `manager-test-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + // Retrieve through abort + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toEqual(collectedUsage); + expect(abortResult.collectedUsage).toHaveLength(2); + + await GenerationJobManager.destroy(); + }); + + it('should return empty collectedUsage when none set', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `no-usage-test-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toEqual([]); + + await GenerationJobManager.destroy(); + }); + + it('should not set collectedUsage if job does not exist', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + }); + + await GenerationJobManager.initialize(); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + // This should not throw, just silently do nothing + GenerationJobManager.setCollectedUsage('non-existent-stream', collectedUsage); + + const abortResult = await GenerationJobManager.abortJob('non-existent-stream'); + expect(abortResult.success).toBe(false); + + await GenerationJobManager.destroy(); + }); +}); + +describe('AbortJob - Text and CollectedUsage', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should extract text from content parts on abort', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `text-extract-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Set content parts with text + const contentParts = [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world!' }, + ]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.text).toBe('Hello world!'); + expect(abortResult.success).toBe(true); + + await GenerationJobManager.destroy(); + }); + + it('should return empty text when no content parts', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `empty-text-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.text).toBe(''); + + await GenerationJobManager.destroy(); + }); + + it('should return both text and collectedUsage on abort', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `full-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Set content parts + const contentParts = [{ type: 'text', text: 'Partial response...' }]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + // Set collected usage + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.success).toBe(true); + expect(abortResult.text).toBe('Partial response...'); + expect(abortResult.collectedUsage).toEqual(collectedUsage); + expect(abortResult.content).toHaveLength(1); + + await GenerationJobManager.destroy(); + }); + + it('should return empty values for non-existent job', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + }); + + await GenerationJobManager.initialize(); + + const abortResult = await GenerationJobManager.abortJob('non-existent-job'); + + expect(abortResult.success).toBe(false); + expect(abortResult.text).toBe(''); + expect(abortResult.collectedUsage).toEqual([]); + expect(abortResult.content).toEqual([]); + expect(abortResult.jobData).toBeNull(); + + await GenerationJobManager.destroy(); + }); +}); + +describe('Real-world Scenarios', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should handle parallel agent abort with collected usage', async () => { + /** + * Scenario: User aborts a conversation with addedConvo (parallel agents) + * - Primary agent: gemini-3-flash-preview + * - Parallel agent: gpt-5.2 + * Both should have their tokens spent on abort + */ + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `parallel-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Simulate content from primary agent + const contentParts = [ + { type: 'text', text: 'Primary agent output...' }, + { type: 'text', text: 'More content...' }, + ]; + GenerationJobManager.setContentParts(streamId, contentParts as never); + + // Simulate collected usage from both agents (as would happen during generation) + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 31596, + output_tokens: 151, + model: 'gemini-3-flash-preview', + }, + { + input_tokens: 28000, + output_tokens: 120, + model: 'gpt-5.2', + }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + // Abort the job + const abortResult = await GenerationJobManager.abortJob(streamId); + + // Verify both models' usage is returned + expect(abortResult.success).toBe(true); + expect(abortResult.collectedUsage).toHaveLength(2); + expect(abortResult.collectedUsage[0].model).toBe('gemini-3-flash-preview'); + expect(abortResult.collectedUsage[1].model).toBe('gpt-5.2'); + + // Verify text is extracted + expect(abortResult.text).toContain('Primary agent output'); + + await GenerationJobManager.destroy(); + }); + + it('should handle abort with cache tokens from Anthropic', async () => { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `cache-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Anthropic-style cache tokens + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 788, + output_tokens: 163, + cache_creation_input_tokens: 30808, + cache_read_input_tokens: 0, + model: 'claude-opus-4-5-20251101', + }, + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage[0].cache_creation_input_tokens).toBe(30808); + + await GenerationJobManager.destroy(); + }); + + it('should handle abort with sequential tool calls usage', async () => { + /** + * Scenario: Single agent with multiple tool calls, aborted mid-execution + * Usage accumulates for each LLM call + */ + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + GenerationJobManager.configure({ + jobStore: new InMemoryJobStore(), + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + + await GenerationJobManager.initialize(); + + const streamId = `sequential-abort-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1'); + + // Usage from multiple sequential LLM calls (tool use pattern) + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, // Initial call + { input_tokens: 150, output_tokens: 30, model: 'gpt-4' }, // After tool result 1 + { input_tokens: 180, output_tokens: 20, model: 'gpt-4' }, // After tool result 2 (aborted here) + ]; + GenerationJobManager.setCollectedUsage(streamId, collectedUsage); + + const abortResult = await GenerationJobManager.abortJob(streamId); + + expect(abortResult.collectedUsage).toHaveLength(3); + // All three entries should be present for proper token accounting + + await GenerationJobManager.destroy(); + }); +}); diff --git a/packages/api/src/stream/implementations/InMemoryJobStore.ts b/packages/api/src/stream/implementations/InMemoryJobStore.ts index e4a5d5d3ad..cc82a69963 100644 --- a/packages/api/src/stream/implementations/InMemoryJobStore.ts +++ b/packages/api/src/stream/implementations/InMemoryJobStore.ts @@ -1,7 +1,12 @@ import { logger } from '@librechat/data-schemas'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; -import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfaces/IJobStore'; +import type { + SerializableJobData, + UsageMetadata, + IJobStore, + JobStatus, +} from '~/stream/interfaces/IJobStore'; /** * Content state for a job - volatile, in-memory only. @@ -10,6 +15,7 @@ import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfa interface ContentState { contentParts: Agents.MessageContentComplex[]; graphRef: WeakRef | null; + collectedUsage: UsageMetadata[]; } /** @@ -240,6 +246,7 @@ export class InMemoryJobStore implements IJobStore { this.contentState.set(streamId, { contentParts: [], graphRef: new WeakRef(graph), + collectedUsage: [], }); } } @@ -252,10 +259,30 @@ export class InMemoryJobStore implements IJobStore { if (existing) { existing.contentParts = contentParts; } else { - this.contentState.set(streamId, { contentParts, graphRef: null }); + this.contentState.set(streamId, { contentParts, graphRef: null, collectedUsage: [] }); } } + /** + * Set collected usage reference for a job. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + const existing = this.contentState.get(streamId); + if (existing) { + existing.collectedUsage = collectedUsage; + } else { + this.contentState.set(streamId, { contentParts: [], graphRef: null, collectedUsage }); + } + } + + /** + * Get collected usage for a job. + */ + getCollectedUsage(streamId: string): UsageMetadata[] { + const state = this.contentState.get(streamId); + return state?.collectedUsage ?? []; + } + /** * Get content parts for a job. * Returns live content from stored reference. diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index 421fa30f2c..cce636d5a1 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -1,9 +1,14 @@ import { logger } from '@librechat/data-schemas'; import { createContentAggregator } from '@librechat/agents'; -import type { IJobStore, SerializableJobData, JobStatus } from '~/stream/interfaces/IJobStore'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; import type { Redis, Cluster } from 'ioredis'; +import type { + SerializableJobData, + UsageMetadata, + IJobStore, + JobStatus, +} from '~/stream/interfaces/IJobStore'; /** * Key prefixes for Redis storage. @@ -90,6 +95,13 @@ export class RedisJobStore implements IJobStore { */ private localGraphCache = new Map>(); + /** + * Local cache for collectedUsage arrays. + * Generation happens on a single instance, so collectedUsage is only available locally. + * For cross-replica abort, the abort handler falls back to text-based token counting. + */ + private localCollectedUsageCache = new Map(); + /** Cleanup interval in ms (1 minute) */ private cleanupIntervalMs = 60000; @@ -227,6 +239,7 @@ export class RedisJobStore implements IJobStore { async deleteJob(streamId: string): Promise { // Clear local caches this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); // Note: userJobs cleanup is handled lazily via self-healing in getActiveJobIdsByUser // In cluster mode, separate runningJobs (global) from stream-specific keys (same slot) @@ -290,6 +303,7 @@ export class RedisJobStore implements IJobStore { if (!job) { await this.redis.srem(KEYS.runningJobs, streamId); this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); cleaned++; continue; } @@ -298,6 +312,7 @@ export class RedisJobStore implements IJobStore { if (job.status !== 'running') { await this.redis.srem(KEYS.runningJobs, streamId); this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); cleaned++; continue; } @@ -382,6 +397,7 @@ export class RedisJobStore implements IJobStore { } // Clear local caches this.localGraphCache.clear(); + this.localCollectedUsageCache.clear(); // Don't close the Redis connection - it's shared logger.info('[RedisJobStore] Destroyed'); } @@ -406,11 +422,28 @@ export class RedisJobStore implements IJobStore { * No-op for Redis - content parts are reconstructed from chunks. * Metadata (agentId, groupId) is embedded directly on content parts by the agent runtime. */ - setContentParts(_streamId: string, _contentParts: Agents.MessageContentComplex[]): void { + setContentParts(): void { // Content parts are reconstructed from chunks during getContentParts // No separate storage needed } + /** + * Store collectedUsage reference in local cache. + * This is used for abort handling to spend tokens for all models. + * Note: Only available on the generating instance; cross-replica abort uses fallback. + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void { + this.localCollectedUsageCache.set(streamId, collectedUsage); + } + + /** + * Get collected usage for a job. + * Only available if this is the generating instance. + */ + getCollectedUsage(streamId: string): UsageMetadata[] { + return this.localCollectedUsageCache.get(streamId) ?? []; + } + /** * Get aggregated content - tries local cache first, falls back to Redis reconstruction. * @@ -528,6 +561,7 @@ export class RedisJobStore implements IJobStore { clearContentState(streamId: string): void { // Clear local caches immediately this.localGraphCache.delete(streamId); + this.localCollectedUsageCache.delete(streamId); // Fire and forget - async cleanup for Redis this.clearContentStateAsync(streamId).catch((err) => { diff --git a/packages/api/src/stream/index.ts b/packages/api/src/stream/index.ts index 4e9bab324c..74c13a2bf0 100644 --- a/packages/api/src/stream/index.ts +++ b/packages/api/src/stream/index.ts @@ -5,11 +5,12 @@ export { } from './GenerationJobManager'; export type { - AbortResult, SerializableJobData, + IEventTransport, + UsageMetadata, + AbortResult, JobStatus, IJobStore, - IEventTransport, } from './interfaces/IJobStore'; export { createStreamServices } from './createStreamServices'; diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index 14611a7fad..af681fb2e9 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -45,6 +45,54 @@ export interface SerializableJobData { promptTokens?: number; } +/** + * Usage metadata for token spending across different LLM providers. + * + * This interface supports two mutually exclusive cache token formats: + * + * **OpenAI format** (GPT-4, o1, etc.): + * - Uses `input_token_details.cache_creation` and `input_token_details.cache_read` + * - Cache tokens are nested under the `input_token_details` object + * + * **Anthropic format** (Claude models): + * - Uses `cache_creation_input_tokens` and `cache_read_input_tokens` + * - Cache tokens are top-level properties + * + * When processing usage data, check both formats: + * ```typescript + * const cacheCreation = usage.input_token_details?.cache_creation + * || usage.cache_creation_input_tokens || 0; + * ``` + */ +export interface UsageMetadata { + /** Total input tokens (prompt tokens) */ + input_tokens?: number; + /** Total output tokens (completion tokens) */ + output_tokens?: number; + /** Model identifier that generated this usage */ + model?: string; + /** + * OpenAI-style cache token details. + * Present for OpenAI models (GPT-4, o1, etc.) + */ + input_token_details?: { + /** Tokens written to cache */ + cache_creation?: number; + /** Tokens read from cache */ + cache_read?: number; + }; + /** + * Anthropic-style cache creation tokens. + * Present for Claude models. Mutually exclusive with input_token_details. + */ + cache_creation_input_tokens?: number; + /** + * Anthropic-style cache read tokens. + * Present for Claude models. Mutually exclusive with input_token_details. + */ + cache_read_input_tokens?: number; +} + /** * Result returned from aborting a job - contains all data needed * for token spending and message saving without storing callbacks @@ -58,6 +106,10 @@ export interface AbortResult { content: Agents.MessageContentComplex[]; /** Final event to send to client */ finalEvent: unknown; + /** Concatenated text from all content parts for token counting fallback */ + text: string; + /** Collected usage metadata from all models for token spending */ + collectedUsage: UsageMetadata[]; } /** @@ -210,6 +262,23 @@ export interface IJobStore { * @param runSteps - Run steps to save */ saveRunSteps?(streamId: string, runSteps: Agents.RunStep[]): Promise; + + /** + * Set collected usage reference for a job. + * This array accumulates token usage from all models during generation. + * + * @param streamId - The stream identifier + * @param collectedUsage - Array of usage metadata from all models + */ + setCollectedUsage(streamId: string, collectedUsage: UsageMetadata[]): void; + + /** + * Get collected usage for a job. + * + * @param streamId - The stream identifier + * @returns Array of usage metadata or empty array + */ + getCollectedUsage(streamId: string): UsageMetadata[]; } /** From f09eec846253e50d47bf2c88ae8ca969e5beffcf Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:45:07 -0800 Subject: [PATCH 044/282] =?UTF-8?q?=E2=9C=85=20feat:=20Zod=20Email=20Valid?= =?UTF-8?q?ation=20at=20Login=20(#11434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Auth/LoginForm.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 0a6e1e8614..c51c2002e3 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -5,6 +5,7 @@ import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; +import { validateEmail } from '~/utils'; import { useLocalize } from '~/hooks'; type TLoginFormProps = { @@ -96,10 +97,9 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, {...register('email', { required: localize('com_auth_email_required'), maxLength: { value: 120, message: localize('com_auth_email_max_length') }, - pattern: { - value: useUsernameLogin ? /\S+/ : /\S+@\S+\.\S+/, - message: localize('com_auth_email_pattern'), - }, + validate: useUsernameLogin + ? undefined + : (value) => validateEmail(value, localize('com_auth_email_pattern')), })} aria-invalid={!!errors.email} className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" From c5113a75a0ca16444f38c46c05d5bfdbb7e036f5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Jan 2026 14:45:27 -0500 Subject: [PATCH 045/282] =?UTF-8?q?=F0=9F=94=A7=20fix:=20Add=20`hasAgentAc?= =?UTF-8?q?cess`=20to=20dependencies=20in=20`useNewConvo`=20hook=20(#11427?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated the dependency array in the useNewConvo hook to include hasAgentAccess for improved state management and functionality. --- client/src/hooks/useNewConvo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index fd2e20e0ee..c468ab30a2 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -249,7 +249,7 @@ const useNewConvo = (index = 0) => { state: disableFocus ? {} : { focusChat: true }, }); }, - [endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data], + [endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess], ); const newConversation = useCallback( From 24e182d20e64cb7ea8179d2975c28c2a42005d6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:25:02 -0500 Subject: [PATCH 046/282] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index f17bb9cb46..89a5d0552d 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -1398,7 +1398,7 @@ "com_ui_upload_image_input": "AugÅ”upielādēt failu kā attēlu", "com_ui_upload_invalid": "NederÄ«gs augÅ”upielādējamais fails. Attēlam jābÅ«t tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "NederÄ«gs augÅ”upielādējams fails. Attēlam jābÅ«t ne lielākam par {{0}} MB", - "com_ui_upload_ocr_text": "AugÅ”upielādēt failu kā tekstu", + "com_ui_upload_ocr_text": "AugÅ”upielādēt failu kā kontekstu", "com_ui_upload_provider": "AugÅ”upielādēt pakalpojumu sniedzējam", "com_ui_upload_success": "Fails veiksmÄ«gi augÅ”upielādēts", "com_ui_upload_type": "Izvēlieties augÅ”upielādes veidu", From e608c652e56f43994d5e6dfba70c367e7fee5f59 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:44:20 -0800 Subject: [PATCH 047/282] =?UTF-8?q?=E2=9C=82=EF=B8=8F=20fix:=20Clipped=20F?= =?UTF-8?q?ocus=20Outlines=20in=20Conversation=20Panel=20(#11438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: focus outline clipping in Conversations panel * chore: address Copilot comments --- client/src/components/Conversations/Conversations.tsx | 2 +- client/src/components/Conversations/Convo.tsx | 2 +- client/src/components/Conversations/ConvoLink.tsx | 2 +- client/src/components/Nav/Bookmarks/BookmarkNav.tsx | 1 + client/src/components/Nav/Favorites/FavoriteItem.tsx | 2 +- client/src/components/Nav/Favorites/FavoritesList.tsx | 2 +- client/src/components/Nav/NewChat.tsx | 6 +++--- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b972d251b0..fc66c0977a 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -82,7 +82,7 @@ const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { return ( {isSelected && ( -
- - - -
+ <> +