diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 905cadfd235..ebc22308545 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -612,12 +612,23 @@ class BaseClient { }); } - logger.debug('[BaseClient] Response token usage', { - messageId: responseMessage.messageId, - model: responseMessage.model, - promptTokens, - completionTokens, - }); + /** Surface input-token count on the in-memory response so the debug + * footer can read it. For agents this is a LangFuse-style cross-step + * sum (see AgentClient.getAggregateUsage); for non-agent clients it + * falls back to the single-call promptTokens estimate. */ + const aggregateInput = usage != null ? Number(usage[this.inputTokensKey]) : NaN; + const promptTokensNum = Number(promptTokens); + const pickedInput = + Number.isFinite(aggregateInput) && aggregateInput > 0 + ? aggregateInput + : Number.isFinite(promptTokensNum) && promptTokensNum > 0 + ? promptTokensNum + : 0; + responseMessage.promptTokens = pickedInput; + + logger.info( + `[BaseClient] Response token usage messageId=${responseMessage.messageId} model=${responseMessage.model} aggregateInput=${aggregateInput} promptTokens=${promptTokens} pickedInput=${pickedInput} completionTokens=${completionTokens} usageInputKey=${this.inputTokensKey} usagePresent=${usage != null} usageJson=${JSON.stringify(usage)}`, + ); } if (userMessagePromise) { @@ -667,7 +678,12 @@ class BaseClient { user, ); this.savedMessageIds.add(responseMessage.messageId); - delete responseMessage.tokenCount; + // Previously: `delete responseMessage.tokenCount` here stripped output + // token count from the in-memory response before sending the final SSE + // event to the client. The DB save (above) already captured it, but the + // streamed response lost it, so the client UI never saw the count without + // a manual conversation refetch. Keeping it on the in-memory object so + // the Debug Mode footer (and any other consumer) can read it directly. return responseMessage; } diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 8adb43f9459..ce8b3876f54 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -329,11 +329,25 @@ const loadTools = async ({ loadAuthValues, webSearchConfig: webSearch, }); + /** Per-conversation Keenable search-profile override. + * ApiKeyDialog writes the user's selection to `ephemeralAgent.web_search_profile`, + * which the frontend ships in the chat-send body. Prefer that over any + * user-level / yaml default so multiple open tabs each keep their own profile. */ + const convoProfile = options?.req?.body?.ephemeralAgent?.web_search_profile; + if (typeof convoProfile === 'string' && convoProfile.length > 0) { + result.authResult.searchProfile = convoProfile; + } + /** Per-conversation pro-mode toggle. When false, set topResults: 0 so + * @librechat/agents skips the per-source /v1/fetch + rerank phase + * entirely (snippet-only fast path). */ + const convoProMode = options?.req?.body?.ephemeralAgent?.web_search_pro_mode; + const overrideTopResults = convoProMode === false ? { topResults: 0 } : {}; const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {}; requestedTools[tool] = async () => { toolContextMap[tool] = buildWebSearchContext(); return createSearchTool({ ...result.authResult, + ...overrideTopResults, onSearchResults, onGetHighlights, logger, diff --git a/api/package.json b/api/package.json index 61a65429b77..6be0c9aa97d 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.68", + "@librechat/agents": "github:keenableai/agents#b86372e", "@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 71fd843b0f1..0cbb2a5a43a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -681,10 +681,38 @@ class AgentClient extends BaseClient { } /** - * Get stream usage as returned by this client's API response. + * LangFuse-style aggregate of all generations in this run: sum of + * input_tokens and output_tokens across every LLM call (initial, + * post-tool-result, summarization). Used by the debug footer's + * `in:` / `out:` fields so they reflect total tokens the user paid + * for across multi-step agent runs, not just the first call. + * @returns {UsageMetadata} + */ + getAggregateUsage() { + const acc = { input_tokens: 0, output_tokens: 0 }; + if (!Array.isArray(this.collectedUsage)) { + return acc; + } + for (const u of this.collectedUsage) { + if (!u) { + continue; + } + acc.input_tokens += Number(u.input_tokens) || 0; + acc.output_tokens += Number(u.output_tokens) || 0; + } + return acc; + } + + /** + * Returns LangFuse-style cross-step aggregate when collectedUsage is + * populated; otherwise falls back to the billing-shaped this.usage. * @returns {UsageMetadata} The stream usage object. */ getStreamUsage() { + const aggregate = this.getAggregateUsage(); + if (aggregate.input_tokens > 0 || aggregate.output_tokens > 0) { + return aggregate; + } return this.usage; } diff --git a/api/server/index.js b/api/server/index.js index d26a203c0a2..c72d8036022 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -1,4 +1,21 @@ require('dotenv').config(); + +// Suppress noisy langchain-core MessageChunk merge warnings emitted by +// providers (Cerebras, Baseten, ...) that include `usage` on every stream +// chunk. They fire hundreds of times per response and don't indicate a +// real problem. +const _origWarn = console.warn; +console.warn = (...args) => { + const first = args[0]; + if ( + typeof first === 'string' && + /already exists in this message chunk and value has unsupported type/.test(first) + ) { + return; + } + _origWarn(...args); +}; + const fs = require('fs'); const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..') }); diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 1bedcec66f6..0d6d056b411 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -98,11 +98,17 @@ export default function BadgeRowProvider({ const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${storageSuffix}`; const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageSuffix}`; + const webSearchProfileKey = `${LocalStorageKeys.LAST_WEB_SEARCH_PROFILE_}${storageSuffix}`; + const webSearchProModeKey = `${LocalStorageKeys.LAST_WEB_SEARCH_PRO_MODE_}${storageSuffix}`; + const debugModeKey = `${LocalStorageKeys.LAST_DEBUG_MODE_}${storageSuffix}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`; const codeToggleValue = getTimestampedValue(codeToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); + const webSearchProfileValue = getTimestampedValue(webSearchProfileKey); + const webSearchProModeValue = getTimestampedValue(webSearchProModeKey); + const debugModeValue = getTimestampedValue(debugModeKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); @@ -124,6 +130,39 @@ export default function BadgeRowProvider({ } } + if (webSearchProfileValue !== null) { + try { + const parsed = JSON.parse(webSearchProfileValue); + if (typeof parsed === 'string' && parsed.length > 0) { + initialValues['web_search_profile'] = parsed; + } + } catch (e) { + console.error('Failed to parse web search profile value:', e); + } + } + + if (webSearchProModeValue !== null) { + try { + const parsed = JSON.parse(webSearchProModeValue); + if (typeof parsed === 'boolean') { + initialValues['web_search_pro_mode'] = parsed; + } + } catch (e) { + console.error('Failed to parse web search pro-mode value:', e); + } + } + + if (debugModeValue !== null) { + try { + const parsed = JSON.parse(debugModeValue); + if (typeof parsed === 'boolean') { + initialValues['debug_mode'] = parsed; + } + } catch (e) { + console.error('Failed to parse debug-mode value:', e); + } + } + if (fileSearchToggleValue !== null) { try { initialValues[Tools.file_search] = JSON.parse(fileSearchToggleValue); diff --git a/client/src/components/Chat/Input/ToolDialogs.tsx b/client/src/components/Chat/Input/ToolDialogs.tsx index 350d0b81e7b..4ed09f6ae0a 100644 --- a/client/src/components/Chat/Input/ToolDialogs.tsx +++ b/client/src/components/Chat/Input/ToolDialogs.tsx @@ -47,6 +47,7 @@ function ToolDialogs() { isOpen={searchDialogOpen} onRevoke={searchHandleRevoke} register={searchMethods.register} + setValue={searchMethods.setValue} onOpenChange={setSearchDialogOpen} handleSubmit={searchMethods.handleSubmit} triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]} diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx index 0b103c2ea7c..5d428e0ae7d 100644 --- a/client/src/components/Chat/Input/ToolsDropdown.tsx +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -8,6 +8,7 @@ import { Permissions, ArtifactModes, PermissionTypes, + SearchProviders, defaultAgentCapabilities, } from 'librechat-data-provider'; import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks'; @@ -78,11 +79,16 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {}; const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {}; + const isKeenableProvider = startupConfig?.webSearch?.searchProvider === SearchProviders.KEENABLE; + const showWebSearchSettings = useMemo(() => { const authTypes = webSearchAuthData?.authTypes ?? []; if (authTypes.length === 0) return true; + /** Keenable exposes a per-conversation searchProfile picker in the dialog, + * so the gear should remain accessible even when all auth is system-defined. */ + if (isKeenableProvider) return true; return !authTypes.every(([, authType]) => authType === AuthType.SYSTEM_DEFINED); - }, [webSearchAuthData?.authTypes]); + }, [webSearchAuthData?.authTypes, isKeenableProvider]); const showCodeSettings = useMemo( () => codeAuthData?.message !== AuthType.SYSTEM_DEFINED, diff --git a/client/src/components/Chat/Input/WebSearch.tsx b/client/src/components/Chat/Input/WebSearch.tsx index 08d11fa3080..c17e1e56a06 100644 --- a/client/src/components/Chat/Input/WebSearch.tsx +++ b/client/src/components/Chat/Input/WebSearch.tsx @@ -1,9 +1,12 @@ import React, { memo } from 'react'; import { Globe } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { CheckboxButton } from '@librechat/client'; -import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import { Permissions, PermissionTypes, Constants, SearchProviders } from 'librechat-data-provider'; import { useLocalize, useHasAccess } from '~/hooks'; import { useBadgeRowContext } from '~/Providers'; +import { useGetStartupConfig } from '~/data-provider'; +import { ephemeralAgentByConvoId } from '~/store'; function WebSearch() { const localize = useLocalize(); @@ -12,6 +15,9 @@ function WebSearch() { permission: Permissions.USE, }); const context = useBadgeRowContext(); + const { data: startupConfig } = useGetStartupConfig(); + const convoKey = context?.conversationId ?? Constants.NEW_CONVO; + const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(convoKey)); if (!canUseWebSearch) { return null; } @@ -22,6 +28,15 @@ function WebSearch() { const { toggleState: webSearch, debouncedChange, isPinned, authData } = webSearchData; const { badgeTriggerRef } = searchApiKeyForm; + /** Show the active Keenable search profile in the button label so it's + * visible at a glance: "Search · google" / "Search · parallel". Only + * surfaced when web search is on and the provider is keenable. */ + const isKeenable = startupConfig?.webSearch?.searchProvider === SearchProviders.KEENABLE; + const activeProfile = ephemeralAgent?.web_search_profile; + const baseLabel = localize('com_ui_search'); + const label = + webSearch && isKeenable && activeProfile ? `${baseLabel} · ${activeProfile}` : baseLabel; + return ( (isPinned || (webSearch && authData?.authenticated)) && ( } /> diff --git a/client/src/components/Chat/Messages/DebugFooter.tsx b/client/src/components/Chat/Messages/DebugFooter.tsx new file mode 100644 index 00000000000..3b05a029ffe --- /dev/null +++ b/client/src/components/Chat/Messages/DebugFooter.tsx @@ -0,0 +1,85 @@ +import React, { memo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Constants } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; +import { ephemeralAgentByConvoId, messageMetricsByIdAtom } from '~/store'; +import { useBadgeRowContext } from '~/Providers'; + +/** + * Renders a small red footer under each non-user message with timing and + * token metrics for debug mode: + * - TTFT — time from submit to the first stream event of any kind + * (thinking start, tool call, content delta). + * - TTFVT — time from submit to the first user-visible content token. + * - e2e — time from submit to stream completion. + * - in — total input tokens summed across every LLM generation in the + * run (LangFuse-style trace aggregate). + * - out — total output tokens summed across every LLM generation. + * + * Timings are captured client-side in the SSE pipeline; tokens come from + * the backend on the streamed responseMessage. + */ +function DebugFooter({ message }: { message: TMessage }) { + const ctx = useBadgeRowContext(); + const messageConvoKey = (message?.conversationId as string) ?? Constants.NEW_CONVO; + const ctxConvoKey = ctx?.conversationId ?? Constants.NEW_CONVO; + const messageAgent = useRecoilValue(ephemeralAgentByConvoId(messageConvoKey)); + const ctxAgent = useRecoilValue(ephemeralAgentByConvoId(ctxConvoKey)); + const newAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO)); + const debugMode = + messageAgent?.debug_mode ?? ctxAgent?.debug_mode ?? newAgent?.debug_mode ?? false; + + const metrics = useRecoilValue(messageMetricsByIdAtom(message?.messageId ?? '')); + + if (!debugMode) { + return null; + } + if (message?.isCreatedByUser) { + return null; + } + + const ttftMs = + metrics.firstTokenAt != null && metrics.submittedAt != null + ? Math.max(0, metrics.firstTokenAt - metrics.submittedAt) + : null; + const ttfvtMs = + metrics.firstVisibleAt != null && metrics.submittedAt != null + ? Math.max(0, metrics.firstVisibleAt - metrics.submittedAt) + : null; + const e2eMs = + metrics.finishedAt != null && metrics.submittedAt != null + ? Math.max(0, metrics.finishedAt - metrics.submittedAt) + : null; + + const m = message as unknown as Record; + const outTokens = + typeof m?.tokenCount === 'number' + ? (m.tokenCount as number) + : typeof m?.summaryTokenCount === 'number' + ? (m.summaryTokenCount as number) + : null; + const inTokens = + typeof m?.promptTokens === 'number' + ? (m.promptTokens as number) + : typeof m?.inputTokens === 'number' + ? (m.inputTokens as number) + : null; + + const fmtSec = (ms: number | null) => (ms == null ? '—' : `${(ms / 1000).toFixed(2)}s`); + const fmtTok = (t: number | null | undefined) => (t == null ? '—' : `${t}`); + + return ( + + {`TTFT: ${fmtSec(ttftMs)}`} + {` · TTFVT: ${fmtSec(ttfvtMs)}`} + {` · e2e: ${fmtSec(e2eMs)}`} + {` · in: ${fmtTok(inTokens)}`} + {` · out: ${fmtTok(outTokens)}`} + + ); +} + +export default memo(DebugFooter); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 2d0e3d512bf..7aa26fb2f9f 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -11,6 +11,7 @@ import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; +import DebugFooter from './DebugFooter'; import SubRow from './SubRow'; import store from '~/store'; @@ -173,6 +174,7 @@ export default function Message(props: TMessageProps) { /> )} + diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index dd556360364..2cb50903e23 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -9,6 +9,7 @@ import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; +import DebugFooter from '~/components/Chat/Messages/DebugFooter'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import SubRow from '~/components/Chat/Messages/SubRow'; import { fontSizeAtom } from '~/store/fontSize'; @@ -259,6 +260,7 @@ const MessageRender = memo(function MessageRender({ /> )} + diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 4ba8db36f8e..98a82495cbe 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -9,6 +9,7 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; +import DebugFooter from '~/components/Chat/Messages/DebugFooter'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import SubRow from '~/components/Chat/Messages/SubRow'; import { fontSizeAtom } from '~/store/fontSize'; @@ -249,6 +250,7 @@ const ContentRender = memo(function ContentRender({ /> )} + diff --git a/client/src/components/SidePanel/Agents/Search/Action.tsx b/client/src/components/SidePanel/Agents/Search/Action.tsx index b79a188814d..80cfe3f75a2 100644 --- a/client/src/components/SidePanel/Agents/Search/Action.tsx +++ b/client/src/components/SidePanel/Agents/Search/Action.tsx @@ -1,6 +1,6 @@ import { KeyRoundIcon } from 'lucide-react'; import { useRef } from 'react'; -import { AuthType, AgentCapabilities } from 'librechat-data-provider'; +import { AuthType, AgentCapabilities, SearchProviders } from 'librechat-data-provider'; import { useFormContext, Controller, useWatch } from 'react-hook-form'; import { CircleHelpIcon, @@ -12,6 +12,7 @@ import { } from '@librechat/client'; import type { AgentForm } from '~/common'; import { useLocalize, useSearchApiKeyForm } from '~/hooks'; +import { useGetStartupConfig } from '~/data-provider'; import ApiKeyDialog from './ApiKeyDialog'; import { ESide } from '~/common'; import { cn } from '~/utils'; @@ -46,6 +47,8 @@ export default function Action({ const webSearchIsEnabled = useWatch({ control, name: AgentCapabilities.web_search }); const isUserProvided = authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED); + const { data: startupConfig } = useGetStartupConfig(); + const isKeenableProvider = startupConfig?.webSearch?.searchProvider === SearchProviders.KEENABLE; const handleCheckboxChange = (checked: boolean) => { if (isToolAuthenticated) { @@ -90,7 +93,7 @@ export default function Action({ {localize('com_ui_web_search')} - {isUserProvided && ( + {(isUserProvided || isKeenableProvider) && ( ; handleSubmit: UseFormHandleSubmit; + setValue?: UseFormSetValue; triggerRef?: React.RefObject; triggerRefs?: React.RefObject[]; }) { const localize = useLocalize(); const { data: config } = useGetStartupConfig(); + const ctx = useBadgeRowContext(); + const convoKey = ctx?.conversationId ?? Constants.NEW_CONVO; + const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(convoKey)); const [selectedProvider, setSelectedProvider] = useState( config?.webSearch?.searchProvider || SearchProviders.SERPER, @@ -48,6 +60,16 @@ export default function ApiKeyDialog({ const [selectedScraper, setSelectedScraper] = useState( config?.webSearch?.scraperProvider || ScraperProviders.FIRECRAWL, ); + /** Derived directly from the recoil atom (NOT useState) so the value stays + * correct after BadgeRowContext hydrates ephemeralAgent from localStorage. + * Earlier we held this in useState which captured a stale `default` before + * hydration finished, even though the request body shipped the real value. */ + const selectedProfile = + ephemeralAgent?.web_search_profile || + (config?.webSearch?.searchProfile as string) || + SearchProfiles.DEFAULT; + const proMode = ephemeralAgent?.web_search_pro_mode ?? true; + const debugMode = ephemeralAgent?.debug_mode ?? false; const providerOptions: DropdownOption[] = [ { @@ -154,10 +176,17 @@ export default function ApiKeyDialog({ }, ]; + const profileOptions: DropdownOption[] = Object.values(SearchProfiles).map((p) => ({ + key: p, + label: p, + inputs: {}, + })); + const [dropdownOpen, setDropdownOpen] = useState({ provider: false, reranker: false, scraper: false, + profile: false, }); const providerAuthType = authTypes.find(([cat]) => cat === SearchCategories.PROVIDERS)?.[1]; @@ -176,6 +205,35 @@ export default function ApiKeyDialog({ setSelectedScraper(key as ScraperProviders); }; + const handleProfileChange = (key: string) => { + /** Persist to per-conversation ephemeralAgent (single source of truth) + * + localStorage so refresh keeps the selection. selectedProfile is + * derived from the atom so it updates in the next render without a + * separate useState write. */ + setEphemeralAgent((prev) => ({ ...(prev ?? {}), web_search_profile: key })); + setTimestampedValue( + `${LocalStorageKeys.LAST_WEB_SEARCH_PROFILE_}${convoKey}`, + JSON.stringify(key), + ); + }; + + const handleProModeChange = (next: boolean) => { + setEphemeralAgent((prev) => ({ ...(prev ?? {}), web_search_pro_mode: next })); + setTimestampedValue( + `${LocalStorageKeys.LAST_WEB_SEARCH_PRO_MODE_}${convoKey}`, + JSON.stringify(next), + ); + }; + + const handleDebugModeChange = (next: boolean) => { + setEphemeralAgent((prev) => ({ ...(prev ?? {}), debug_mode: next })); + setTimestampedValue(`${LocalStorageKeys.LAST_DEBUG_MODE_}${convoKey}`, JSON.stringify(next)); + }; + + useEffect(() => { + setValue?.('searchProfile', selectedProfile); + }, [setValue, selectedProfile]); + return ( )} + + {/* Search Profile Section (only meaningful when provider is keenable) */} + {selectedProvider === SearchProviders.KEENABLE && ( + <> + + setDropdownOpen((prev) => ({ ...prev, profile: open })) + } + dropdownKey="profile" + /> + + {/* eslint-disable-next-line i18next/no-literal-string */} + Pro Mode + + handleProModeChange(e.target.checked)} + /> + {} + + {proMode ? 'Scrape + rerank top results' : 'Snippets only (fast)'} + + + + > + )} + + {/* Debug Mode — independent of search provider */} + + {} + {'Debug Mode'} + + handleDebugModeChange(e.target.checked)} + /> + {} + {'Show TTFT, latency, and token counts under each reply'} + + > } diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 18aaf0daee3..aa6b660304a 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -29,7 +29,7 @@ import type { TAskFunction, ExtendedFile } from '~/common'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; import useGetSender from '~/hooks/Conversations/useGetSender'; import { logger, createDualMessageContent } from '~/utils'; -import store, { useGetEphemeralAgent } from '~/store'; +import store, { useGetEphemeralAgent, useRecordMessageMetric } from '~/store'; import { startupConfigKey } from '~/data-provider'; import useUserKey from '~/hooks/Input/useUserKey'; import { useAuthContext } from '~/hooks'; @@ -75,6 +75,7 @@ export default function useChatFunctions({ const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1)); + const recordMessageMetric = useRecordMessageMetric(); const ask: TAskFunction = ( { @@ -343,6 +344,8 @@ export default function useChatFunctions({ setLatestMessage(initialResponse); } + recordMessageMetric(initialResponse.messageId, { submittedAt: Date.now() }); + setSubmission(submission); logger.dir('message_stream', submission, { depth: null }); }; diff --git a/client/src/hooks/Plugins/useAuthSearchTool.ts b/client/src/hooks/Plugins/useAuthSearchTool.ts index bd5f41fe789..f4cdbf613eb 100644 --- a/client/src/hooks/Plugins/useAuthSearchTool.ts +++ b/client/src/hooks/Plugins/useAuthSearchTool.ts @@ -17,6 +17,8 @@ export type SearchApiKeyFormData = { jinaApiKey: string; jinaApiUrl: string; cohereApiKey: string; + // Keenable upstream-engine selector (sent as `profile` on POST /v1/search) + searchProfile: string; }; const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { @@ -48,6 +50,8 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { const installTool = useCallback( (data: SearchApiKeyFormData) => { + // searchProfile intentionally omitted: it's per-conversation state on + // the ephemeralAgent (see ApiKeyDialog), not per-user plugin auth. const auth = Object.entries({ serperApiKey: data.serperApiKey, searxngInstanceUrl: data.searxngInstanceUrl, diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 366775c4c13..78618e662ab 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -6,6 +6,7 @@ import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { QueryKeys, Constants, + StepEvents, EndpointURLs, ContentTypes, tPresetSchema, @@ -34,6 +35,7 @@ import { findConversationInInfinite, } from '~/utils'; import { startupConfigKey, queueTitleGeneration } from '~/data-provider'; +import { useRecordMessageMetric, useCopyMessageMetrics } from '~/store/messageMetrics'; import useAttachmentHandler from '~/hooks/SSE/useAttachmentHandler'; import useContentHandler from '~/hooks/SSE/useContentHandler'; import useStepHandler from '~/hooks/SSE/useStepHandler'; @@ -187,8 +189,17 @@ export default function useEventHandlers({ const { conversationId: paramId } = useParams(); const { token } = useAuthContext(); - const { contentHandler, resetContentHandler } = useContentHandler({ setMessages, getMessages }); - const { stepHandler, clearStepMaps, syncStepMessage } = useStepHandler({ + const recordMessageMetric = useRecordMessageMetric(); + const copyMessageMetrics = useCopyMessageMetrics(); + const { contentHandler: rawContentHandler, resetContentHandler } = useContentHandler({ + setMessages, + getMessages, + }); + const { + stepHandler: rawStepHandler, + clearStepMaps, + syncStepMessage, + } = useStepHandler({ setMessages, getMessages, announcePolite, @@ -197,6 +208,55 @@ export default function useEventHandlers({ }); const attachmentHandler = useAttachmentHandler(queryClient); + /** + * Wrap contentHandler to record TTFT (any first event) and TTFVT + * (first visible text content). Tool calls and thinking count toward + * TTFT but not TTFVT. + */ + const contentHandler = useCallback( + (params: Parameters[0]) => { + const responseMessageId = params?.submission?.initialResponse?.messageId; + if (responseMessageId) { + const now = Date.now(); + recordMessageMetric(responseMessageId, { firstTokenAt: now }); + if (params?.data?.type === ContentTypes.TEXT) { + recordMessageMetric(responseMessageId, { firstVisibleAt: now }); + } + } + return rawContentHandler(params); + }, + [rawContentHandler, recordMessageMetric], + ); + + /** + * Wrap stepHandler to record TTFT (any event) and TTFVT (only when the + * step carries visible text content — message deltas with type 'text'). + * Tool calls and thinking bump TTFT only. + */ + const stepHandler = useCallback( + (...args: Parameters) => { + const stepEvent = args[0] as { event?: string; data?: unknown } | undefined; + const submission = args[1] as EventSubmission | undefined; + const responseMessageId = submission?.initialResponse?.messageId; + if (responseMessageId) { + const now = Date.now(); + recordMessageMetric(responseMessageId, { firstTokenAt: now }); + + if (stepEvent?.event === StepEvents.ON_MESSAGE_DELTA) { + const delta = stepEvent.data as { delta?: { content?: unknown } } | undefined; + const content = delta?.delta?.content; + const contentPart = Array.isArray(content) ? content[0] : content; + const partType = (contentPart as { type?: string } | undefined)?.type; + if (partType === ContentTypes.TEXT || partType === ContentTypes.TEXT_DELTA) { + recordMessageMetric(responseMessageId, { firstVisibleAt: now }); + } + } + } + return rawStepHandler(...args); + }, + [rawStepHandler, recordMessageMetric], + ); + const messageHandler = useCallback( (data: string | undefined, submission: EventSubmission) => { const { messages, userMessage, initialResponse, isRegenerate = false } = submission; @@ -204,6 +264,12 @@ export default function useEventHandlers({ setIsSubmitting(true); const currentTime = Date.now(); + if (initialResponse?.messageId) { + recordMessageMetric(initialResponse.messageId, { + firstTokenAt: currentTime, + firstVisibleAt: currentTime, + }); + } if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) { announcePolite({ message: 'composing', isStatus: true }); lastAnnouncementTimeRef.current = currentTime; @@ -228,7 +294,7 @@ export default function useEventHandlers({ ]); } }, - [setMessages, announcePolite, setIsSubmitting], + [setMessages, announcePolite, setIsSubmitting, recordMessageMetric], ); const cancelHandler = useCallback( @@ -263,9 +329,28 @@ export default function useEventHandlers({ }); } + const finishedAt = Date.now(); + const cancelInitialId = submission.initialResponse?.messageId; + const cancelResponseId = responseMessage?.messageId; + if (cancelInitialId) { + recordMessageMetric(cancelInitialId, { finishedAt }); + } + if (cancelResponseId) { + copyMessageMetrics(cancelInitialId, cancelResponseId); + recordMessageMetric(cancelResponseId, { finishedAt }); + } + setIsSubmitting(false); }, - [setMessages, setConversation, isAddedRequest, queryClient, setIsSubmitting], + [ + setMessages, + setConversation, + isAddedRequest, + queryClient, + setIsSubmitting, + recordMessageMetric, + copyMessageMetrics, + ], ); const syncHandler = useCallback( @@ -597,6 +682,16 @@ export default function useEventHandlers({ } } } finally { + const finishedAt = Date.now(); + const initialId = submission.initialResponse?.messageId; + const respId = responseMessage?.messageId; + if (initialId) { + recordMessageMetric(initialId, { finishedAt }); + } + if (respId) { + copyMessageMetrics(initialId, respId); + recordMessageMetric(respId, { finishedAt }); + } setShowStopButton(false); setIsSubmitting(false); } @@ -615,6 +710,8 @@ export default function useEventHandlers({ location.pathname, applyAgentTemplate, attachmentHandler, + recordMessageMetric, + copyMessageMetrics, ], ); diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 25d721e65bd..5a59e9032c6 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -15,6 +15,7 @@ import isTemporary from './temporary'; export * from './agents'; export * from './mcp'; export * from './favorites'; +export * from './messageMetrics'; export default { ...artifacts, diff --git a/client/src/store/messageMetrics.ts b/client/src/store/messageMetrics.ts new file mode 100644 index 00000000000..18919404938 --- /dev/null +++ b/client/src/store/messageMetrics.ts @@ -0,0 +1,78 @@ +import { atomFamily, useRecoilCallback } from 'recoil'; + +export type MessageMetrics = { + /** When the user pressed send (T0) */ + submittedAt?: number; + /** First SSE event of any kind (TTFT — includes thinking, tool calls, content) */ + firstTokenAt?: number; + /** First user-visible content token (TTFVT — text content only) */ + firstVisibleAt?: number; + /** When the stream finished or aborted */ + finishedAt?: number; +}; + +/** + * Per-message client-side timing atom keyed by the assistant responseMessageId. + * Populated by the chat submit + SSE handlers; consumed by DebugFooter. + * + * In-memory only — page reload wipes timings, which is acceptable for a + * debug overlay. Survives navigation within an open session. + */ +export const messageMetricsByIdAtom = atomFamily({ + key: 'messageMetricsById', + default: {}, +}); + +function mergeMetrics(prev: MessageMetrics, patch: Partial): MessageMetrics { + const next: MessageMetrics = { ...prev }; + if (patch.submittedAt != null && next.submittedAt == null) { + next.submittedAt = patch.submittedAt; + } + if (patch.firstTokenAt != null && next.firstTokenAt == null) { + next.firstTokenAt = patch.firstTokenAt; + } + if (patch.firstVisibleAt != null && next.firstVisibleAt == null) { + next.firstVisibleAt = patch.firstVisibleAt; + } + if (patch.finishedAt != null) { + next.finishedAt = patch.finishedAt; + } + return next; +} + +export function useRecordMessageMetric() { + return useRecoilCallback( + ({ set }) => + (messageId: string | undefined, patch: Partial) => { + if (!messageId) { + return; + } + set(messageMetricsByIdAtom(messageId), (prev) => mergeMetrics(prev, patch)); + }, + [], + ); +} + +/** + * Copy metrics recorded under a temporary client-side id (the placeholder + * initialResponse.messageId, which ends in `_`) onto the server-assigned + * final responseMessage.messageId so the DebugFooter — which renders with + * the final id — can find the timings. + */ +export function useCopyMessageMetrics() { + return useRecoilCallback( + ({ snapshot, set }) => + (fromId: string | undefined, toId: string | undefined) => { + if (!fromId || !toId || fromId === toId) { + return; + } + const source = + snapshot.getLoadable(messageMetricsByIdAtom(fromId)).valueMaybe() ?? undefined; + if (!source) { + return; + } + set(messageMetricsByIdAtom(toId), (target) => mergeMetrics(target, source)); + }, + [], + ); +} diff --git a/librechat.example.yaml b/librechat.example.yaml index 66f790e5471..22f3f6839b0 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -585,6 +585,12 @@ endpoints: # # Content scrapers # firecrawlApiKey: '${FIRECRAWL_API_KEY}' # firecrawlApiUrl: '${FIRECRAWL_API_URL}' +# # Keenable search profile (optional, only when searchProvider: keenable). +# # Forwarded as the `profile` field on POST /v1/search. +# # Valid: default, keenable-gq, google, bing, brave, brave_llm, exa, +# # exa_instant, tavily, tavily_fast, tavily_ultra_fast, perplexity, +# # perplexity_pro, parallel, parallel_advanced, yandex +# searchProfile: 'google' # Memory configuration for user memories # memory: diff --git a/package-lock.json b/package-lock.json index 7af4d148740..c3379a95583 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.1.68", + "@librechat/agents": "github:keenableai/agents#b86372e", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -138,6 +138,43 @@ "supertest": "^7.1.0" } }, + "api/node_modules/@librechat/agents": { + "version": "3.1.68", + "resolved": "git+ssh://git@github.com/keenableai/agents.git#b86372e7bd9b32d2a5b111313d82351f96124c63", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", + "@aws-sdk/client-bedrock-runtime": "^3.1013.0", + "@langchain/anthropic": "^0.3.26", + "@langchain/aws": "^0.1.15", + "@langchain/core": "^0.3.80", + "@langchain/deepseek": "^0.0.2", + "@langchain/google-genai": "^0.2.18", + "@langchain/google-vertexai": "^0.2.18", + "@langchain/langgraph": "^0.4.9", + "@langchain/mistralai": "^0.2.1", + "@langchain/openai": "0.5.18", + "@langchain/textsplitters": "^0.1.0", + "@langchain/xai": "^0.0.3", + "@langfuse/langchain": "^4.3.0", + "@langfuse/otel": "^4.3.0", + "@langfuse/tracing": "^4.3.0", + "@opentelemetry/sdk-node": "^0.207.0", + "@scarf/scarf": "^1.4.0", + "ai-tokenizer": "^1.0.6", + "axios": "^1.15.0", + "cheerio": "^1.0.0", + "dotenv": "^16.4.7", + "https-proxy-agent": "^7.0.6", + "mathjs": "^15.2.0", + "nanoid": "^3.3.7", + "okapibm25": "^1.4.1", + "openai": "5.8.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "api/node_modules/@node-saml/node-saml": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", @@ -11917,6 +11954,7 @@ "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.68.tgz", "integrity": "sha512-lTfyPJkgOHdUFauIAcjt+DnJyTq6ApJWlX2UOqXJtLXmLilLBMl1fx0wb8PnwspB+8sEOm/8gte319wu2cRATw==", "license": "MIT", + "peer": true, "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a4a13e4b0bb..9a2cdd4847c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -865,6 +865,7 @@ export type TStartupConfig = { searchProvider?: SearchProviders; scraperProvider?: ScraperProviders; rerankerType?: RerankerTypes; + searchProfile?: SearchProfiles | string; }; mcpServers?: Record< string, @@ -918,6 +919,31 @@ export enum RerankerTypes { KEENABLE = 'keenable', } +/** + * Keenable search profiles. Forwarded as the `profile` field on POST /v1/search + * when `searchProvider` is `keenable`. Each value selects a different upstream + * search engine on the Keenable backend; values match the wire format the + * Keenable API expects (verified live, all 16 return 200). + */ +export enum SearchProfiles { + DEFAULT = 'default', + KEENABLE_GQ = 'keenable-gq', + GOOGLE = 'google', + BING = 'bing', + BRAVE = 'brave', + BRAVE_LLM = 'brave_llm', + EXA = 'exa', + EXA_INSTANT = 'exa_instant', + TAVILY = 'tavily', + TAVILY_FAST = 'tavily_fast', + TAVILY_ULTRA_FAST = 'tavily_ultra_fast', + PERPLEXITY = 'perplexity', + PERPLEXITY_PRO = 'perplexity_pro', + PARALLEL = 'parallel', + PARALLEL_ADVANCED = 'parallel_advanced', + YANDEX = 'yandex', +} + export enum SafeSearchTypes { OFF = 0, MODERATE = 1, @@ -939,6 +965,7 @@ export const webSearchSchema = z.object({ searchProvider: z.nativeEnum(SearchProviders).optional(), scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), + searchProfile: z.string().optional().default('${KEENABLE_SEARCH_PROFILE}'), scraperTimeout: z.number().int().nonnegative().optional(), safeSearch: z.nativeEnum(SafeSearchTypes).default(SafeSearchTypes.MODERATE), firecrawlOptions: z @@ -1949,6 +1976,12 @@ export enum LocalStorageKeys { LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_', /** Last checked toggle for Web Search per conversation ID */ LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_', + /** Last selected Keenable search profile per conversation ID */ + LAST_WEB_SEARCH_PROFILE_ = 'LAST_WEB_SEARCH_PROFILE_', + /** Last per-conversation pro-mode toggle (false = snippet-only) */ + LAST_WEB_SEARCH_PRO_MODE_ = 'LAST_WEB_SEARCH_PRO_MODE_', + /** Last per-conversation debug-mode toggle (true = render TTFT/latency/token footer) */ + LAST_DEBUG_MODE_ = 'LAST_DEBUG_MODE_', /** Last checked toggle for File Search per conversation ID */ LAST_FILE_SEARCH_TOGGLE_ = 'LAST_FILE_SEARCH_TOGGLE_', /** Last checked toggle for Artifacts per conversation ID */ diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 85ac417f6cf..bd01e82adff 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -99,6 +99,16 @@ export type TEndpointOption = Pick< export type TEphemeralAgent = { mcp?: string[]; web_search?: boolean; + /** Keenable search profile selection for this conversation. Forwarded as the + * `profile` body field on POST /v1/search; per-conversation so multiple + * open tabs don't trample each other. */ + web_search_profile?: string; + /** When false, skip the per-source /v1/fetch + rerank phase and use only + * /v1/search snippets. Per-conversation, default true (full pro mode). */ + web_search_pro_mode?: boolean; + /** When true, the chat UI renders a small red footer under each assistant + * message with TTFT, end-to-end latency, and token counts. Per-conversation. */ + debug_mode?: boolean; file_search?: boolean; execute_code?: boolean; artifacts?: string; diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts index 953ac936aef..9fa00b2cc28 100644 --- a/packages/data-schemas/src/app/web.ts +++ b/packages/data-schemas/src/app/web.ts @@ -16,6 +16,8 @@ export const webSearchAuth = { keenableApiKey: 1 as const, /** Optional (0) */ keenableApiUrl: 0 as const, + /** Optional (0) — selects which upstream engine Keenable uses (e.g. google/bing/exa). */ + searchProfile: 0 as const, }, }, scrapers: { @@ -89,6 +91,7 @@ export function loadWebSearchConfig( const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; const keenableApiKey = config?.keenableApiKey ?? '${KEENABLE_API_KEY}'; const keenableApiUrl = config?.keenableApiUrl ?? '${KEENABLE_API_URL}'; + const searchProfile = config?.searchProfile ?? '${KEENABLE_SEARCH_PROFILE}'; const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; return { @@ -99,6 +102,7 @@ export function loadWebSearchConfig( cohereApiKey, keenableApiKey, keenableApiUrl, + searchProfile, serperApiKey, searxngApiKey, firecrawlApiKey, diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index 9879efae557..76ddd87e274 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -44,6 +44,9 @@ const messageSchema: Schema = new Schema( tokenCount: { type: Number, }, + promptTokens: { + type: Number, + }, summaryTokenCount: { type: Number, }, diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index 201e5650efb..e846a948009 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -13,6 +13,7 @@ export interface IMessage extends Document { invocationId?: number; parentMessageId?: string | null; tokenCount?: number; + promptTokens?: number; summaryTokenCount?: number; sender?: string; text?: string; diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts index 0d51cde6621..298501f3482 100644 --- a/packages/data-schemas/src/types/web.ts +++ b/packages/data-schemas/src/types/web.ts @@ -11,7 +11,8 @@ export type TWebSearchKeys = | 'jinaApiUrl' | 'cohereApiKey' | 'keenableApiKey' - | 'keenableApiUrl'; + | 'keenableApiUrl' + | 'searchProfile'; export type TWebSearchCategories = | SearchCategories.PROVIDERS