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)) && (