From 7eec6aab3ebc2822e7b78c0541c1d52ed98bdb4c Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 30 Apr 2026 16:15:57 -0700 Subject: [PATCH 01/20] feat(web-search): Keenable searchProfile end-to-end (config + per-user dialog) Adds a `searchProfile` selector that flows from the LibreChat frontend through to Keenable's POST /v1/search as the `profile` body field, picking which upstream search engine Keenable uses (default, google, bing, brave, exa, exa_instant, tavily, tavily_fast, tavily_ultra_fast, perplexity, perplexity_pro, parallel, parallel_advanced, brave_llm, keenable-gq, yandex). Wire values verified live against api.keenable.ai (all 16 return 200). Fork-incompatible kscraper variants intentionally excluded. Schema / backend: - packages/data-provider/src/config.ts: SearchProfiles enum + searchProfile field on webSearchSchema (z.string with KEENABLE_SEARCH_PROFILE env-var default so per-user overrides plumb through extractWebSearchEnvVars). - packages/data-schemas/src/app/web.ts: searchProfile registered as optional (0) under webSearchAuth.providers.keenable so the standard loadAuthValues flow resolves the user's stored value. - packages/data-schemas/src/types/web.ts: 'searchProfile' added to TWebSearchKeys. - packages/data-schemas/src/app/web.ts: loadWebSearchConfig defaults searchProfile to the env-var ref. - packages/api/src/web/web.ts: existing dispatcher now propagates authResult.searchProfile via the standard category iteration; the earlier custom propagation block is removed. Frontend dialog: - client/src/hooks/Plugins/useAuthSearchTool.ts: searchProfile added to SearchApiKeyFormData and the install auth dict. - client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx: new "Search Profile" InputSection that renders only when searchProvider is keenable; setValue prop wires the dropdown into the RHF form so the selected value is submitted. - client/src/components/SidePanel/Agents/Search/Action.tsx and client/src/components/Chat/Input/ToolDialogs.tsx: pass setValue down. Yaml docs: - librechat.example.yaml: documents the new searchProfile field. Companion change in @librechat/agents (keenableai/agents#feat/search-profile) adds searchProfile through SearchConfig -> createKeenableSearchAPI and includes it as `profile` in the POST body when set. --- .../src/components/Chat/Input/ToolDialogs.tsx | 1 + .../SidePanel/Agents/Search/Action.tsx | 1 + .../SidePanel/Agents/Search/ApiKeyDialog.tsx | 43 ++++++++++++++++++- client/src/hooks/Plugins/useAuthSearchTool.ts | 3 ++ librechat.example.yaml | 6 +++ packages/data-provider/src/config.ts | 27 ++++++++++++ packages/data-schemas/src/app/web.ts | 4 ++ packages/data-schemas/src/types/web.ts | 3 +- 8 files changed, 85 insertions(+), 3 deletions(-) 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/SidePanel/Agents/Search/Action.tsx b/client/src/components/SidePanel/Agents/Search/Action.tsx index b79a188814d..ad5b67caf80 100644 --- a/client/src/components/SidePanel/Agents/Search/Action.tsx +++ b/client/src/components/SidePanel/Agents/Search/Action.tsx @@ -127,6 +127,7 @@ export default function Action({ onRevoke={handleRevokeApiKey} onOpenChange={setIsDialogOpen} register={keyFormMethods.register} + setValue={keyFormMethods.setValue} isToolAuthenticated={isToolAuthenticated} handleSubmit={keyFormMethods.handleSubmit} triggerRef={apiKeyButtonRef} diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index a7ec8fdc17d..b93b7229c2a 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -1,14 +1,15 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button, OGDialog, OGDialogTemplate } from '@librechat/client'; import { AuthType, RerankerTypes, + SearchProfiles, SearchProviders, ScraperProviders, SearchCategories, } from 'librechat-data-provider'; import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool'; -import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form'; +import type { UseFormRegister, UseFormHandleSubmit, UseFormSetValue } from 'react-hook-form'; import InputSection, { type DropdownOption } from './InputSection'; import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; @@ -22,6 +23,7 @@ export default function ApiKeyDialog({ isToolAuthenticated, register, handleSubmit, + setValue, triggerRef, triggerRefs, }: { @@ -33,6 +35,7 @@ export default function ApiKeyDialog({ isToolAuthenticated: boolean; register: UseFormRegister; handleSubmit: UseFormHandleSubmit; + setValue?: UseFormSetValue; triggerRef?: React.RefObject; triggerRefs?: React.RefObject[]; }) { @@ -48,6 +51,9 @@ export default function ApiKeyDialog({ const [selectedScraper, setSelectedScraper] = useState( config?.webSearch?.scraperProvider || ScraperProviders.FIRECRAWL, ); + const [selectedProfile, setSelectedProfile] = useState( + (config?.webSearch?.searchProfile as string) || SearchProfiles.DEFAULT, + ); const providerOptions: DropdownOption[] = [ { @@ -154,10 +160,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 +189,15 @@ export default function ApiKeyDialog({ setSelectedScraper(key as ScraperProviders); }; + const handleProfileChange = (key: string) => { + setSelectedProfile(key); + setValue?.('searchProfile', key); + }; + + 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" + /> + )} } diff --git a/client/src/hooks/Plugins/useAuthSearchTool.ts b/client/src/hooks/Plugins/useAuthSearchTool.ts index bd5f41fe789..856c5e45eb6 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 }) => { @@ -57,6 +59,7 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => { jinaApiKey: data.jinaApiKey, jinaApiUrl: data.jinaApiUrl, cohereApiKey: data.cohereApiKey, + searchProfile: data.searchProfile, }).reduce( (acc, [key, value]) => { if (value) { 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/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a4a13e4b0bb..51f6e3f0587 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 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/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 From 38c4cd293d6745fab523c8db4d218035020df9aa Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 30 Apr 2026 17:19:45 -0700 Subject: [PATCH 02/20] chore(deps): pin @librechat/agents to keenableai fork search-profile branch Pins to commit a6d89c8 on keenableai/agents so the searchProfile plumbing ships in this image build. The fork's prepare script builds dist/ when installed from a git URL, so no npm publish is required. Once a 3.1.68-keenable.X is published to npm with this work merged, this pin should be replaced with a normal semver dependency. --- api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/package.json b/api/package.json index 61a65429b77..c82e284d43b 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#a6d89c8", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", From 3eabb44b58f07623eef8d8e65e457a0fa72ddef1 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 30 Apr 2026 17:27:00 -0700 Subject: [PATCH 03/20] chore(deps): update lockfile for agents git pin --- package-lock.json | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7af4d148740..cb9414a2688 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#a6d89c8", "@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#a6d89c81f4053938b7e4a1a779d132a2a42053af", + "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", From 1cc21a617768fc90862b47471a95cf7028411f07 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 1 May 2026 09:05:26 -0700 Subject: [PATCH 04/20] fix(web-search): show key icon for keenable provider even when system-authenticated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ApiKeyDialog trigger (key icon next to Web Search in agent builder) was gated solely on isUserProvided — true only when at least one auth category needs user input. With KEENABLE_API_KEY in env, all categories are SYSTEM_DEFINED and the icon never rendered, leaving no way for users to open the new Search Profile dropdown. Now the icon also shows when searchProvider is keenable so the profile picker is reachable. No change for non-keenable providers. --- client/src/components/SidePanel/Agents/Search/Action.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/components/SidePanel/Agents/Search/Action.tsx b/client/src/components/SidePanel/Agents/Search/Action.tsx index ad5b67caf80..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) && (
diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index 3364cd67221..61c5a374a1e 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -69,6 +69,7 @@ export default function ApiKeyDialog({ (config?.webSearch?.searchProfile as string) || SearchProfiles.DEFAULT; const proMode = ephemeralAgent?.web_search_pro_mode ?? true; + const debugMode = ephemeralAgent?.debug_mode ?? false; const providerOptions: DropdownOption[] = [ { @@ -224,6 +225,11 @@ export default function ApiKeyDialog({ ); }; + 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]); @@ -327,6 +333,22 @@ export default function ApiKeyDialog({ )} + + {/* Debug Mode — independent of search provider */} +
+ {} +
{'Debug Mode'}
+ +
} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 20991a436b0..9a2cdd4847c 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1980,6 +1980,8 @@ export enum LocalStorageKeys { 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 5308ef1d169..bd01e82adff 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -106,6 +106,9 @@ export type TEphemeralAgent = { /** 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; From 92d26ccd4bea49186dd722734eb8931e79807362 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 1 May 2026 16:43:56 -0700 Subject: [PATCH 14/20] fix(debug-footer): fall back across conversationId atoms during new->uuid transition When user toggles Debug Mode in a 'new' conversation and then sends the first message, the conversation gets a real UUID. The dialog wrote debug_mode under atom['new'] but the rendered assistant message has conversationId=, so the footer's lookup against atom[] was empty and the footer rendered null. Fix: also consult BadgeRowContext's conversationId-keyed atom and the 'new' atom, taking whichever has debug_mode set. --- .../components/Chat/Messages/DebugFooter.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/src/components/Chat/Messages/DebugFooter.tsx b/client/src/components/Chat/Messages/DebugFooter.tsx index 0d57d4299a2..76a51ec1e2d 100644 --- a/client/src/components/Chat/Messages/DebugFooter.tsx +++ b/client/src/components/Chat/Messages/DebugFooter.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import { ephemeralAgentByConvoId } from '~/store'; +import { useBadgeRowContext } from '~/Providers'; /** * Renders a small red footer under each non-user message with the metrics @@ -12,12 +13,23 @@ import { ephemeralAgentByConvoId } from '~/store'; * * TTFT and total input tokens are not yet available without backend * instrumentation; they show as "—" for now. + * + * Reads debug_mode from BOTH the message's conversationId atom AND the + * BadgeRowContext's atom (typically the same, but they diverge briefly + * when a new conversation transitions from 'new' to its real UUID — the + * dialog wrote under 'new', the rendered message has the UUID). */ function DebugFooter({ message }: { message: TMessage }) { - const convoKey = (message?.conversationId as string) ?? Constants.NEW_CONVO; - const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(convoKey)); + 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; - if (!ephemeralAgent?.debug_mode) { + if (!debugMode) { return null; } if (message?.isCreatedByUser) { From 45ee559235d2d08ba0234ddffc8e45a52503d80a Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 1 May 2026 16:48:00 -0700 Subject: [PATCH 15/20] fix(debug-footer): mount in ContentRender + MessageRender too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DebugFooter was only added to MessageParts, which only renders for the 'assistants' endpoint. Custom endpoints (Azure azureOpenAI, Cerebras, Baseten, Fireworks) route through MessageContent → ContentRender, and some pure-text messages go through Message → MessageRender. Adding the footer to all three paths so it shows regardless of endpoint. --- client/src/components/Chat/Messages/ui/MessageRender.tsx | 2 ++ client/src/components/Messages/ContentRender.tsx | 2 ++ 2 files changed, 4 insertions(+) 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({ /> )} + From 0c09f1e2a8c260d2f866c78c0e8636fbe53a80c5 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 1 May 2026 16:56:27 -0700 Subject: [PATCH 16/20] fix(debug-footer): show 0s when updated==created; check alt token fields --- .../components/Chat/Messages/DebugFooter.tsx | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/client/src/components/Chat/Messages/DebugFooter.tsx b/client/src/components/Chat/Messages/DebugFooter.tsx index 76a51ec1e2d..de3c24dc817 100644 --- a/client/src/components/Chat/Messages/DebugFooter.tsx +++ b/client/src/components/Chat/Messages/DebugFooter.tsx @@ -38,9 +38,20 @@ function DebugFooter({ message }: { message: TMessage }) { const created = message?.createdAt ? new Date(message.createdAt).getTime() : null; const updated = message?.updatedAt ? new Date(message.updatedAt).getTime() : null; - const latencyMs = - created != null && updated != null && updated > created ? updated - created : null; - const outTokens = message?.tokenCount; + const latencyMs = created != null && updated != null ? Math.max(0, updated - created) : 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}`); @@ -50,17 +61,14 @@ function DebugFooter({ message }: { message: TMessage }) { className="mt-1 select-text font-mono text-xs text-red-500 dark:text-red-400" data-testid="debug-footer" > - {/* eslint-disable-next-line i18next/no-literal-string */} - TTFT: — - {' · '} - {/* eslint-disable-next-line i18next/no-literal-string */} - e2e: {fmtSec(latencyMs)} - {' · '} - {/* eslint-disable-next-line i18next/no-literal-string */} - in: — - {' · '} - {/* eslint-disable-next-line i18next/no-literal-string */} - out: {fmtTok(outTokens)} + {} + {`TTFT: —`} + {} + {` · e2e: ${fmtSec(latencyMs)}`} + {} + {` · in: ${fmtTok(inTokens)}`} + {} + {` · out: ${fmtTok(outTokens)}`} ); } From a57c1a5df2422e832e7200ab0f9321fd9b1d8886 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 1 May 2026 17:07:41 -0700 Subject: [PATCH 17/20] fix(messages): preserve tokenCount on streamed response so client can show it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseClient was setting responseMessage.tokenCount from the model's output usage, queueing the DB save, then deleting tokenCount from the in-memory object before returning. The DB record was correct, but the streamed final event sent to the client had no tokenCount, so the Debug Mode footer (and any other consumer) saw 'out: —' until a manual conversation refetch rehydrated the saved message. Removing the delete; tokenCount now rides on the streamed response. Affects all clients that go through BaseClient.sendMessage, including the agents pipeline. --- api/app/clients/BaseClient.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 905cadfd235..98d3d52a8d4 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -667,7 +667,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; } From eefb4e4d18210661d448f7584bdc5ce0dcdc9fa6 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 4 May 2026 10:29:39 -0700 Subject: [PATCH 18/20] feat(debug-footer): TTFT, TTFVT, e2e and LangFuse-style aggregate tokens - Backend: aggregate input/output tokens across every LLM generation in an agent run (LangFuse-style sum) by adding `getAggregateUsage` to AgentClient and overriding `getStreamUsage` to prefer the cross-step aggregate over `firstUsage`-only `this.usage`. BaseClient now stashes `responseMessage.promptTokens` from the chosen usage so the streamed response carries input-token totals to the client. - Frontend: capture submittedAt / firstTokenAt / firstVisibleAt / finishedAt in a new `messageMetricsByIdAtom` (recoil atomFamily). TTFT bumps on the first event of any kind (content delta, step, tool call). TTFVT bumps only on visible text content. After finalHandler/cancelHandler, metrics are copied from the placeholder initialResponse messageId to the server-assigned responseMessage id so the renderer can find them. - DebugFooter shows TTFT, TTFVT, e2e (all derived from local timings), plus in: (promptTokens) and out: (tokenCount) from the streamed response. --- api/app/clients/BaseClient.js | 8 ++ api/server/controllers/agents/client.js | 30 +++++- .../components/Chat/Messages/DebugFooter.tsx | 51 ++++++----- client/src/hooks/Chat/useChatFunctions.ts | 5 +- client/src/hooks/SSE/useEventHandlers.ts | 91 ++++++++++++++++++- client/src/store/index.ts | 1 + client/src/store/messageMetrics.ts | 78 ++++++++++++++++ 7 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 client/src/store/messageMetrics.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 98d3d52a8d4..1812e1424f6 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -612,6 +612,14 @@ class BaseClient { }); } + /** 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]) : 0; + responseMessage.promptTokens = + Number.isFinite(aggregateInput) && aggregateInput > 0 ? aggregateInput : promptTokens; + logger.debug('[BaseClient] Response token usage', { messageId: responseMessage.messageId, model: responseMessage.model, 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/client/src/components/Chat/Messages/DebugFooter.tsx b/client/src/components/Chat/Messages/DebugFooter.tsx index de3c24dc817..3b05a029ffe 100644 --- a/client/src/components/Chat/Messages/DebugFooter.tsx +++ b/client/src/components/Chat/Messages/DebugFooter.tsx @@ -2,22 +2,22 @@ import React, { memo } from 'react'; import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; -import { ephemeralAgentByConvoId } from '~/store'; +import { ephemeralAgentByConvoId, messageMetricsByIdAtom } from '~/store'; import { useBadgeRowContext } from '~/Providers'; /** - * Renders a small red footer under each non-user message with the metrics - * that are easily extractable from the message object: - * - End-to-end latency (updatedAt − createdAt) - * - Output token count (message.tokenCount) + * 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. * - * TTFT and total input tokens are not yet available without backend - * instrumentation; they show as "—" for now. - * - * Reads debug_mode from BOTH the message's conversationId atom AND the - * BadgeRowContext's atom (typically the same, but they diverge briefly - * when a new conversation transitions from 'new' to its real UUID — the - * dialog wrote under 'new', the rendered message has the UUID). + * 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(); @@ -29,6 +29,8 @@ function DebugFooter({ message }: { message: TMessage }) { const debugMode = messageAgent?.debug_mode ?? ctxAgent?.debug_mode ?? newAgent?.debug_mode ?? false; + const metrics = useRecoilValue(messageMetricsByIdAtom(message?.messageId ?? '')); + if (!debugMode) { return null; } @@ -36,9 +38,19 @@ function DebugFooter({ message }: { message: TMessage }) { return null; } - const created = message?.createdAt ? new Date(message.createdAt).getTime() : null; - const updated = message?.updatedAt ? new Date(message.updatedAt).getTime() : null; - const latencyMs = created != null && updated != null ? Math.max(0, updated - created) : 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' @@ -61,13 +73,10 @@ function DebugFooter({ message }: { message: TMessage }) { className="mt-1 select-text font-mono text-xs text-red-500 dark:text-red-400" data-testid="debug-footer" > - {} - {`TTFT: —`} - {} - {` · e2e: ${fmtSec(latencyMs)}`} - {} + {`TTFT: ${fmtSec(ttftMs)}`} + {` · TTFVT: ${fmtSec(ttfvtMs)}`} + {` · e2e: ${fmtSec(e2eMs)}`} {` · in: ${fmtTok(inTokens)}`} - {} {` · out: ${fmtTok(outTokens)}`} ); 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/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 366775c4c13..becaa4e9e8f 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -34,6 +34,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 +188,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 +207,42 @@ 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 only (steps are tool/thinking events, + * never user-visible text — they don't bump TTFVT). + */ + const stepHandler = useCallback( + (...args: Parameters) => { + const submission = args[1] as EventSubmission | undefined; + const responseMessageId = submission?.initialResponse?.messageId; + if (responseMessageId) { + recordMessageMetric(responseMessageId, { firstTokenAt: Date.now() }); + } + return rawStepHandler(...args); + }, + [rawStepHandler, recordMessageMetric], + ); + const messageHandler = useCallback( (data: string | undefined, submission: EventSubmission) => { const { messages, userMessage, initialResponse, isRegenerate = false } = submission; @@ -204,6 +250,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 +280,7 @@ export default function useEventHandlers({ ]); } }, - [setMessages, announcePolite, setIsSubmitting], + [setMessages, announcePolite, setIsSubmitting, recordMessageMetric], ); const cancelHandler = useCallback( @@ -263,9 +315,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 +668,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 +696,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)); + }, + [], + ); +} From b0c3e0bc17d8105e17eb4398c719b735bb792d5b Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 4 May 2026 10:57:12 -0700 Subject: [PATCH 19/20] debug-footer: TTFVT on agent message_delta + log + always-numeric promptTokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: agent text streams via stepHandler ON_MESSAGE_DELTA, not contentHandler. Bump TTFVT when the message_delta carries a TEXT contentPart. (Tool calls, thinking, agent_update events still bump TTFT only.) - Backend: assign responseMessage.promptTokens with explicit numeric fallback chain (aggregate → single-call estimate → 0) so the field is always a finite number. Replace debug log with info-level log surfacing aggregateInput / promptTokens / pickedInput so we can see why a value is null in production logs. --- api/app/clients/BaseClient.js | 20 +++++++++++++++----- client/src/hooks/SSE/useEventHandlers.ts | 20 +++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 1812e1424f6..05cdd46c6c9 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -616,15 +616,25 @@ class BaseClient { * 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]) : 0; - responseMessage.promptTokens = - Number.isFinite(aggregateInput) && aggregateInput > 0 ? aggregateInput : promptTokens; - - logger.debug('[BaseClient] Response token usage', { + 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, promptTokens, + pickedInput, completionTokens, + usageInputKey: this.inputTokensKey, + usagePresent: usage != null, }); } diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index becaa4e9e8f..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, @@ -228,15 +229,28 @@ export default function useEventHandlers({ ); /** - * Wrap stepHandler to record TTFT only (steps are tool/thinking events, - * never user-visible text — they don't bump TTFVT). + * 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) { - recordMessageMetric(responseMessageId, { firstTokenAt: Date.now() }); + 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); }, From 72128b8a1d1e91efba934240a9bcfd53a44989cd Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 4 May 2026 11:15:43 -0700 Subject: [PATCH 20/20] fix(debug-footer): add promptTokens to Message DB schema Mongoose strict mode was silently dropping responseMessage.promptTokens on save (and any subsequent fetch), so the field never reached the client even though BaseClient was setting it correctly on the in-memory response. Add promptTokens to both the Mongoose schema and the TS type so the field survives persistence. Also keep the inline info log for one more cycle to confirm the fix end-to-end with real values. --- api/app/clients/BaseClient.js | 13 +++---------- packages/data-schemas/src/schema/message.ts | 3 +++ packages/data-schemas/src/types/message.ts | 1 + 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 05cdd46c6c9..ebc22308545 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -626,16 +626,9 @@ class BaseClient { : 0; responseMessage.promptTokens = pickedInput; - logger.info('[BaseClient] Response token usage', { - messageId: responseMessage.messageId, - model: responseMessage.model, - aggregateInput, - promptTokens, - pickedInput, - completionTokens, - usageInputKey: this.inputTokensKey, - usagePresent: usage != null, - }); + 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) { 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;