feat(web-search): Keenable searchProfile end-to-end (config + per-user dialog)#2
feat(web-search): Keenable searchProfile end-to-end (config + per-user dialog)#2dtaylor072 wants to merge 20 commits into
Conversation
…r 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.
Review Summary by QodoAdd Keenable searchProfile end-to-end configuration and UI
WalkthroughsDescription• Adds searchProfile selector for Keenable upstream search engine selection • Flows from frontend dialog through backend config to Keenable API profile field • Supports 16 verified search profiles (google, bing, brave, exa, tavily, perplexity, etc.) • Server-default via librechat.yaml, per-user override via search settings dialog Diagramflowchart LR
A["Frontend Dialog"] -->|"searchProfile selection"| B["useAuthSearchTool Hook"]
B -->|"SearchApiKeyFormData"| C["ApiKeyDialog Component"]
C -->|"setValue propagation"| D["React Hook Form"]
D -->|"searchProfile value"| E["Backend Config"]
E -->|"env-var default"| F["webSearchSchema"]
F -->|"loadWebSearchConfig"| G["Keenable API"]
G -->|"profile field"| H["Upstream Engine Selection"]
File Changes1. packages/data-provider/src/config.ts
|
Code Review by Qodo
1. Hardcoded Search Profile title
|
…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.
| {/* Search Profile Section (only meaningful when provider is keenable) */} | ||
| {selectedProvider === SearchProviders.KEENABLE && ( | ||
| <InputSection | ||
| title="Search Profile" | ||
| selectedKey={selectedProfile} |
There was a problem hiding this comment.
1. Hardcoded search profile title 📘 Rule violation ⚙ Maintainability
The new dialog section title is hardcoded (Search Profile) instead of using useLocalize(), which breaks localization requirements for user-facing text.
Agent Prompt
## Issue description
A new user-facing UI string (`Search Profile`) is hardcoded instead of being routed through `useLocalize()`.
## Issue Context
This dialog already uses `useLocalize()` for other titles; the new section should follow the same localization pattern and add/update the English source-of-truth key.
## Fix Focus Areas
- client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx[266-270]
- client/src/locales/en/translation.json
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| # # 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' |
There was a problem hiding this comment.
3. Literal yaml profile ignored 🐞 Bug ≡ Correctness
The example config documents searchProfile: 'google', but the web search auth resolver only loads
values when the config string is an env-var placeholder (${...}), so a literal profile will not be
included in authResult and won’t be propagated.
Agent Prompt
### Issue description
`librechat.example.yaml` documents `searchProfile: 'google'`, but the backend web search auth/config resolution only recognizes env-var placeholders (`${KEENABLE_SEARCH_PROFILE}` style). As a result, operators following the example will not get a `searchProfile` value propagated.
### Issue Context
`extractWebSearchEnvVars()` uses `extractVariableName()` which only matches `${ENV_VAR}`; literals (e.g., `google`) are ignored.
### Fix Focus Areas
- librechat.example.yaml[574-594]
- packages/api/src/web/web.ts[49-74]
- packages/data-provider/src/utils.ts[27-35]
### Suggested fix options (pick one)
1) **Documentation-only** (minimal): change the YAML example to use `${KEENABLE_SEARCH_PROFILE}` and instruct setting `KEENABLE_SEARCH_PROFILE=google` via environment.
2) **Support literals in backend** (behavioral): extend the web-search config/auth resolution so optional fields like `searchProfile` can be taken directly from `webSearchConfig.searchProfile` when it’s a non-placeholder literal, while still allowing per-user overrides when placeholders are used.
Implementing option (2) should include tests to cover both `${KEENABLE_SEARCH_PROFILE}` and literal `google` configs.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
…-authenticated 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.
…ovider Mirrors the Action.tsx gating fix: the chat-input ToolsDropdown's gear icon next to "Web Search" was hidden when all auth categories were SYSTEM_DEFINED, blocking access to the new searchProfile picker. Now also shows when searchProvider is keenable so users can open the dialog and pick a profile from chat.
Cerebras/Baseten emit usage on every streaming chunk; langchain-core warns each time it can't merge total_tokens/completion_tokens, producing hundreds of harmless 'field[X] already exists in this message chunk and value has unsupported type' lines per response. Patch console.warn at api entrypoint to drop just those.
…r-user Bug: with multiple tabs open, the searchProfile selected in one tab clobbered the others because the dialog wrote to per-user plugin auth (KEENABLE_SEARCH_PROFILE). loadWebSearchAuth then read the user-level value back regardless of which tab sent the request. Fix: profile lives on TEphemeralAgent (per-conversation, recoil atom keyed by conversationId) and rides in each chat-send payload. Backend prefers ephemeralAgent.web_search_profile over the user-level default. Changes: - packages/data-provider/src/types.ts: web_search_profile?: string on TEphemeralAgent - packages/data-provider/src/config.ts: LAST_WEB_SEARCH_PROFILE_ in LocalStorageKeys for per-conversation persistence across refresh - client/src/Providers/BadgeRowContext.tsx: hydrate web_search_profile into the conversation's ephemeralAgent atom on mount alongside the other tool toggles - client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx: write selected profile to ephemeralAgent (per-conversation) + localStorage on every change; initialize from ephemeralAgent if set - client/src/hooks/Plugins/useAuthSearchTool.ts: stop sending searchProfile in the install plugin-auth payload — it is no longer per-user state - api/app/clients/tools/util/handleTools.js: override authResult.searchProfile from req.body.ephemeralAgent.web_search_profile before createSearchTool is invoked
Adds a checkbox in the search settings dialog that lets each conversation pick between full pro mode (scrape top N URLs + rerank highlights, ~1-3s) and snippet-only fast path (just /v1/search results, ~200-400ms). Companion change in @librechat/agents (b86372e): when topResults=0 is passed to createSourceProcessor, processSources short-circuits and returns the search response without scraping. - TEphemeralAgent.web_search_pro_mode (per-conversation, default true) - LocalStorageKeys.LAST_WEB_SEARCH_PRO_MODE_ for refresh persistence - BadgeRowContext hydrates from localStorage alongside the other tools - ApiKeyDialog: Pro Mode checkbox shown when provider is keenable; writes to ephemeralAgent + localStorage on change - handleTools.js: when ephemeralAgent.web_search_pro_mode === false, pass topResults: 0 to createSearchTool, propagating to the agents short-circuit - api/package.json: bump @librechat/agents pin to b86372e
…void stale state Bug: ApiKeyDialog held selectedProfile/proMode in useState initialized from ephemeralAgent on mount. BadgeRowContext hydrates ephemeralAgent from localStorage in a useEffect — which fires AFTER the dialog has already snapshotted the initial empty atom into local state. Result: the dialog's UI shows 'default' while the actual ephemeralAgent (and the value sent to the backend on chat-send) holds the real persisted value. The user appears to make no change in the dialog so nothing is written, and the stale persisted profile keeps getting used. Fix: derive both values directly from the recoil atom each render. The atom IS the source of truth; the dialog just reflects it. Mutations go through setEphemeralAgent + localStorage and the dialog updates next render automatically.
…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=<uuid>, so the footer's lookup against atom[<uuid>] 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.
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.
… show it 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.
- 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.
…mptTokens - 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.
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.
* 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema The hardcoded `Constants.VERSION` in `data-provider` is now replaced at rollup build time via `@rollup/plugin-replace`, sourcing from the root `package.json` so version bumps are a single-file change. Adds the shape needed by the rest of the series: - `interface.buildInfo` boolean flag (default `true`) — lets self-hosters opt out of exposing commit/branch/date. - `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate. - `SettingsTabValues.ABOUT` — new settings tab enum value. Ref: danny-avila#12406 * 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA, branch, and build date from (in order) `BUILD_*` env vars, then local git metadata. Result is cached per-process. `/api/config` includes a `buildInfo` field on both authenticated and anonymous responses when `interface.buildInfo !== false` and at least one resolver field is populated. Omitted entirely otherwise. Designed so pre-built Docker images carry metadata via build-arg while source installs pick it up from `.git` — no manual version tracking. Ref: danny-avila#12406 * ℹ️ feat: Add Settings → About Panel with Diagnostics Copy New Settings tab that renders the running build's version, commit (short SHA), branch, and build date in a monospaced block alongside a "Copy diagnostics" button that emits a preformatted text blob for pasting into support issues. Tab is hidden when `interface.buildInfo` is set to `false`. Reads from `startupConfig.buildInfo` provided by `/api/config`. Ref: danny-avila#12406 * 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both `Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime stage so the backend's `resolveBuildInfo` picks them up. All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`, `dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a UTC timestamp, then pass them to `docker/build-push-action` as `build-args`. Defaults are empty — non-CI builds (local `docker build`) still work, and the backend falls back to local `.git` metadata if ARGs aren't set. Ref: danny-avila#12406 * 📝 docs: Direct Bug Reporters to Settings → About for Version Info The previous instructions (`docker images | grep librechat`, `git rev-parse HEAD`) only worked for a subset of deployments and rarely produced a commit SHA for users pulling pre-built images. Point users to the new in-app Settings → About panel's "Copy diagnostics" button, which captures version, commit, branch, build date, and user agent in a single preformatted block. Fallback instructions preserved for older installs. Ref: danny-avila#12406 * 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV before `npm ci` / `npm run frontend` (single-stage) and before `npm ci --omit=dev` (multi-stage api-build), which invalidated the cache for every subsequent layer on every CI run. Move the ARG/ENV block below the heavy install and build steps in both Dockerfiles. Metadata is still available in the runtime image but no longer busts layer reuse. Addresses codex review on danny-avila#12756. * 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config The unauthenticated branch of `/api/config` was emitting an `interface` object only when `privacyPolicy` or `termsOfService` was set, which meant an admin's explicit `interface.buildInfo: false` opt-out was never visible to anonymous/guest clients. `Settings.tsx` gates the About tab on `startupConfig?.interface?.buildInfo !== false`, so a missing field fell through as "enabled" for those clients. Include `interface.buildInfo: false` in the unauth payload whenever it's explicitly disabled. Keep the implicit default (true) absent to preserve the minimal-unauth-payload convention. Addresses codex review on danny-avila#12756. * 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes The baked `Constants.VERSION` now reads from the root `package.json` via rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml` path filters only matched `api/**`, `client/**`, `packages/**`. A release commit that only bumps root `package.json` would not trigger a rebuild, leaving `latest` dev images with stale Footer/About version metadata. Include `package.json`, `package-lock.json`, and both Dockerfiles in the path filters so dependency changes (lockfile rebuilds) and image build tweaks also rebuild dev images. Addresses codex review on danny-avila#12756. * 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate Review follow-ups on danny-avila#12756: - #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it from a `useEffect` cleanup so unmounting the Settings dialog mid-toast doesn't fire `setCopied(false)` on an unmounted component. - danny-avila#3 flash of About tab: gate `aboutEnabled` on `startupConfig != null` so the tab stays hidden until `/api/config` returns. For admins who disabled `interface.buildInfo`, the tab no longer briefly appears and vanishes on page load. - danny-avila#6 aria-live placement: move the live region off the interactive button onto a dedicated `<span role="status" aria-live="polite">` so screen readers announce the copied state, not the full button content on every re-render. - #2 missing coverage: add `About.spec.tsx` exercising populated/empty buildInfo rendering, invalid-date handling, diagnostics clipboard payload, copy-state toggling, unmount cleanup, and the live region. * ⚡ perf: Eagerly Resolve Build Info at Module Load Review follow-up danny-avila#4 on danny-avila#12756: `resolveBuildInfo()` calls `execFileSync` with a 2s timeout on source installs without `BUILD_*` env vars. Paying this cost on the first HTTP request blocks the event loop mid-flight. Call `resolveBuildInfo()` once at config route module load so the resolver's cache is warm before any request arrives. Docker images with the BUILD_* env vars set sidestep the git path entirely, so this only affects the edge case of source installs. * 📝 docs: Document rollup Version Placeholder Contract Review follow-ups danny-avila#5 / danny-avila#8 on danny-avila#12756. The `__LIBRECHAT_VERSION__` placeholder relies on a substring replacement rule that only works because the token appears inside a string literal, and the substitution only runs during `npm run build:data-provider`. - Expand the `Constants.VERSION` JSDoc to spell out that consumers read the placeholder through the built dist bundle; source-level test imports would see the raw placeholder. - Add a NOTE above the rollup `replace` config warning future contributors not to repurpose the token as a bare identifier without switching to a quoted replacement value. Non-functional; prevents future contributors from stepping on a subtle constraint. * 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds Codex R5 on danny-avila#12756. `copy-to-clipboard` returns a boolean indicating whether the underlying `execCommand('copy')` / fallback prompt actually wrote to the clipboard. The previous handler flipped to the "Copied" state unconditionally, which in hardened browsers or when the permission prompt is dismissed would mislead users into filing bug reports without the diagnostics blob attached. Gate the state/timer/live-region on the boolean return; silently no-op on failure rather than showing a false positive. Adds a test asserting the button label stays at "Copy diagnostics" when the clipboard call fails. * 🐳 fix: Derive main image metadata from checkout * 🪪 fix: Keep About enabled until disabled * ✅ test: Avoid literal Settings mock text * 🧱 refactor: Rename Build Info Module
Summary
searchProfileselector that flows from the LibreChat frontend through to Keenable'sPOST /v1/searchas theprofilebody field, picking which upstream search engine Keenable uses.librechat.yaml, per-user override via the existing search settings dialog.feat/search-profile.Profiles supported
default,keenable-gq,google,bing,brave,brave_llm,exa,exa_instant,tavily,tavily_fast,tavily_ultra_fast,perplexity,perplexity_pro,parallel,parallel_advanced,yandex— wire values verified live (all 16 return 200). Fork-incompatible kscraper variants intentionally excluded (verified to 400/500/timeout server-side).Files
Schema / backend:
packages/data-provider/src/config.ts:SearchProfilesenum +searchProfilefield onwebSearchSchema(z.stringwith${KEENABLE_SEARCH_PROFILE}env-var default so per-user overrides plumb throughextractWebSearchEnvVars).packages/data-schemas/src/app/web.ts:searchProfileregistered as optional underwebSearchAuth.providers.keenableso the standardloadAuthValuesflow resolves the user's stored value.packages/data-schemas/src/types/web.ts:'searchProfile'added toTWebSearchKeys.packages/data-schemas/src/app/web.ts:loadWebSearchConfigdefaultssearchProfileto the env-var ref.packages/api/src/web/web.ts: dispatcher now propagatesauthResult.searchProfilevia the standard category iteration (earlier ad-hoc propagation block removed).Frontend dialog:
client/src/hooks/Plugins/useAuthSearchTool.ts:searchProfileadded toSearchApiKeyFormDataand the install auth dict.client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx: new "Search Profile"InputSection(renders only whensearchProvideriskeenable);setValueprop wires the dropdown into the RHF form so the selected value is submitted.client/src/components/SidePanel/Agents/Search/Action.tsxandclient/src/components/Chat/Input/ToolDialogs.tsx: passsetValuedown.Yaml docs:
librechat.example.yaml: documents the newsearchProfilefield.Test plan
@librechat/agents@3.1.68-keenable.Xis published: bump dep in this repo'sapi/package.json.docker compose pull && up -don thelibrechatGCE VM.searchProvider: keenable, see new "Search Profile" dropdown, switch values, confirm subsequent searches send the selectedprofilebody field.🤖 Generated with Claude Code