Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7eec6aa
feat(web-search): Keenable searchProfile end-to-end (config + per-use…
Apr 30, 2026
38c4cd2
chore(deps): pin @librechat/agents to keenableai fork search-profile …
May 1, 2026
3eabb44
chore(deps): update lockfile for agents git pin
May 1, 2026
1cc21a6
fix(web-search): show key icon for keenable provider even when system…
May 1, 2026
5f77414
fix(web-search): show settings gear in tools dropdown for keenable pr…
May 1, 2026
f9858ca
chore(deps): bump @librechat/agents pin to 67ee7e4 for keenable timin…
May 1, 2026
e80b251
chore(deps): force lockfile re-resolve for new agents commit
May 1, 2026
cab52bc
chore(deps): bump agents pin to 61203c3 (HTTP interceptor logs)
May 1, 2026
5be2fec
chore(logs): suppress langchain MessageChunk merge warnings
May 1, 2026
04f7eb5
fix(web-search): make Keenable searchProfile per-conversation, not pe…
May 1, 2026
057ba9d
feat(web-search): per-conversation pro-mode toggle (snippets vs scrape)
May 1, 2026
b846423
fix(web-search): derive profile/proMode from atom (not useState) to a…
May 1, 2026
3b36ee5
feat: debug mode + profile in button
May 1, 2026
92d26cc
fix(debug-footer): fall back across conversationId atoms during new->…
May 1, 2026
45ee559
fix(debug-footer): mount in ContentRender + MessageRender too
May 1, 2026
0c09f1e
fix(debug-footer): show 0s when updated==created; check alt token fields
May 1, 2026
a57c1a5
fix(messages): preserve tokenCount on streamed response so client can…
May 2, 2026
eefb4e4
feat(debug-footer): TTFT, TTFVT, e2e and LangFuse-style aggregate tokens
May 4, 2026
b0c3e0b
debug-footer: TTFVT on agent message_delta + log + always-numeric pro…
May 4, 2026
72128b8
fix(debug-footer): add promptTokens to Message DB schema
May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down
14 changes: 14 additions & 0 deletions api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
17 changes: 17 additions & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
@@ -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, '..') });
Expand Down
39 changes: 39 additions & 0 deletions client/src/Providers/BadgeRowContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions client/src/components/Chat/Input/ToolDialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function ToolDialogs() {
isOpen={searchDialogOpen}
onRevoke={searchHandleRevoke}
register={searchMethods.register}
setValue={searchMethods.setValue}
onOpenChange={setSearchDialogOpen}
handleSubmit={searchMethods.handleSubmit}
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
Expand Down
8 changes: 7 additions & 1 deletion client/src/components/Chat/Input/ToolsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Permissions,
ArtifactModes,
PermissionTypes,
SearchProviders,
defaultAgentCapabilities,
} from 'librechat-data-provider';
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions client/src/components/Chat/Input/WebSearch.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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;
}
Expand All @@ -22,14 +28,23 @@ 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)) && (
<CheckboxButton
ref={badgeTriggerRef}
className="max-w-fit"
checked={webSearch}
setValue={debouncedChange}
label={localize('com_ui_search')}
label={label}
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
icon={<Globe className="icon-md" aria-hidden="true" />}
/>
Expand Down
85 changes: 85 additions & 0 deletions client/src/components/Chat/Messages/DebugFooter.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 (
<div
className="mt-1 select-text font-mono text-xs text-red-500 dark:text-red-400"
data-testid="debug-footer"
>
<span>{`TTFT: ${fmtSec(ttftMs)}`}</span>
<span>{` · TTFVT: ${fmtSec(ttfvtMs)}`}</span>
<span>{` · e2e: ${fmtSec(e2eMs)}`}</span>
<span>{` · in: ${fmtTok(inTokens)}`}</span>
<span>{` · out: ${fmtTok(outTokens)}`}</span>
</div>
);
}

export default memo(DebugFooter);
2 changes: 2 additions & 0 deletions client/src/components/Chat/Messages/MessageParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -173,6 +174,7 @@ export default function Message(props: TMessageProps) {
/>
</SubRow>
)}
<DebugFooter message={message} />
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/Chat/Messages/ui/MessageRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -259,6 +260,7 @@ const MessageRender = memo(function MessageRender({
/>
</SubRow>
)}
<DebugFooter message={msg} />
</div>
</div>
</div>
Expand Down
Loading
Loading