diff --git a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx index e9fbc4b1b3..3cfb50e7d9 100644 --- a/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx +++ b/client/src/components/Nav/Settings/__tests__/Sidebar.spec.tsx @@ -10,6 +10,7 @@ const ctx: SettingsContextValue = { hasAnyPersonalizationFeature: false, hasMemoryOptOut: false, hasRemoteAgents: false, + hasUserProvidedEndpoints: false, hasMultiConvo: false, hasPrompts: false, isLocalProvider: true, diff --git a/client/src/components/Nav/Settings/context.tsx b/client/src/components/Nav/Settings/context.tsx index 2d7f5a4901..1126a9096d 100644 --- a/client/src/components/Nav/Settings/context.tsx +++ b/client/src/components/Nav/Settings/context.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { SettingsContextValue } from './types'; +import useProviderKeys from '../SettingsTabs/ProviderKeys/useProviderKeys'; import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; import { useHasAccess, useAuthContext } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; @@ -34,6 +35,7 @@ export function useSettingsContext(): SettingsContextValue { const hasMultiConvoBool = hasMultiConvo === true; const hasPromptsBool = hasPrompts === true; const engineTTS = useRecoilValue(store.engineTTS); + const hasUserProvidedEndpoints = useProviderKeys().length > 0; return useMemo( () => ({ @@ -41,6 +43,7 @@ export function useSettingsContext(): SettingsContextValue { hasAnyPersonalizationFeature, hasMemoryOptOut, hasRemoteAgents: hasRemoteAgentsBool, + hasUserProvidedEndpoints, hasMultiConvo: hasMultiConvoBool, hasPrompts: hasPromptsBool, isLocalProvider, @@ -54,6 +57,7 @@ export function useSettingsContext(): SettingsContextValue { hasAnyPersonalizationFeature, hasMemoryOptOut, hasRemoteAgentsBool, + hasUserProvidedEndpoints, hasMultiConvoBool, hasPromptsBool, isLocalProvider, diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 415a753bef..a2682963ab 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -33,6 +33,7 @@ import { ClearChats } from '../SettingsTabs/Data/ClearChats'; import { TokenCredits, AutoRefill } from './BillingControls'; import SharedLinks from '../SettingsTabs/Data/SharedLinks'; import { showThinkingAtom } from '~/store/showThinking'; +import ProviderKeys from '../SettingsTabs/ProviderKeys'; import Avatar from '../SettingsTabs/Account/Avatar'; import About from '../SettingsTabs/About/About'; import ApiKeys from '../SettingsTabs/ApiKeys'; @@ -466,6 +467,15 @@ export const registry: SettingEntry[] = [ Component: SharedLinks, }, // Data controls · API keys + { + id: 'providerApiKeys', + tab: DATA, + section: 'apiKeys', + labelKey: 'com_ui_settings_label_provider_api_keys', + keywords: ['api', 'key', 'keys', 'provider', 'endpoint', 'credentials'], + show: (ctx) => ctx.hasUserProvidedEndpoints, + Component: ProviderKeys, + }, { id: 'agentApiKeys', tab: DATA, diff --git a/client/src/components/Nav/Settings/types.ts b/client/src/components/Nav/Settings/types.ts index 0d5abd0da6..05a87c9c70 100644 --- a/client/src/components/Nav/Settings/types.ts +++ b/client/src/components/Nav/Settings/types.ts @@ -38,6 +38,7 @@ export interface SettingsContextValue { hasAnyPersonalizationFeature: boolean; hasMemoryOptOut: boolean; hasRemoteAgents: boolean; + hasUserProvidedEndpoints: boolean; hasMultiConvo: boolean; hasPrompts: boolean; isLocalProvider: boolean; diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx new file mode 100644 index 0000000000..1d6a2b2e37 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeyRow.tsx @@ -0,0 +1,92 @@ +import React, { useMemo, useState } from 'react'; +import { Button } from '@librechat/client'; +import { alternateName, getEndpointField } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; +import { SetKeyDialog } from '~/components/Input/SetKeyDialog'; +import { useUserKey, useLocalize } from '~/hooks'; +import { icons } from '~/hooks/Endpoint/Icons'; +import { getIconKey } from '~/utils'; + +interface ProviderKeyRowProps { + endpoint: string; + endpointsConfig: TEndpointsConfig; +} + +export default function ProviderKeyRow({ endpoint, endpointsConfig }: ProviderKeyRowProps) { + const localize = useLocalize(); + const [dialogOpen, setDialogOpen] = useState(false); + const { getExpiry, checkExpiry } = useUserKey(endpoint); + + const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); + const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType }); + const Icon = icons[iconKey]; + + const label = useMemo(() => alternateName[endpoint] || endpoint, [endpoint]); + const expiry = getExpiry(); + const hasKey = !!expiry && checkExpiry(); + const expiryLabel = useMemo(() => { + if (!expiry) { + return localize('com_ui_provider_api_keys_not_set'); + } + if (expiry === 'never') { + return localize('com_endpoint_config_key_never_expires'); + } + return `${localize('com_endpoint_config_key_encryption')} ${new Date(expiry).toLocaleString()}`; + }, [expiry, localize]); + + return ( + <> +
+
+ {Icon && ( + + )} +
+
{label}
+
{expiryLabel}
+
+
+ +
+ {dialogOpen && ( + + )} + + ); +} diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeys.tsx b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeys.tsx new file mode 100644 index 0000000000..e83c4b28fb --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/ProviderKeys.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { + Label, + Button, + OGDialog, + OGDialogTitle, + OGDialogHeader, + InfoHoverCard, + OGDialogTrigger, + OGDialogContent, +} from '@librechat/client'; +import { useGetEndpointsQuery } from '~/data-provider'; +import useProviderKeys from './useProviderKeys'; +import ProviderKeyRow from './ProviderKeyRow'; +import { useLocalize } from '~/hooks'; + +export default function ProviderKeys() { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const endpoints = useProviderKeys(); + + return ( +
+ + + + + + + +
+ {localize('com_ui_settings_label_provider_api_keys')} + +
+
+ {endpointsConfig && ( +
+ {endpoints.map((endpoint) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/index.ts b/client/src/components/Nav/SettingsTabs/ProviderKeys/index.ts new file mode 100644 index 0000000000..d17ee82837 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/index.ts @@ -0,0 +1 @@ +export { default } from './ProviderKeys'; diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/useProviderKeys.ts b/client/src/components/Nav/SettingsTabs/ProviderKeys/useProviderKeys.ts new file mode 100644 index 0000000000..7f7f50d9ae --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/useProviderKeys.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { getUserKeyEndpoints } from './utils'; +import { useHasAccess } from '~/hooks'; + +/** + * Reachable endpoints requiring a user-provided key, filtered the same way the model + * selector and mention popover are (modelSpecs, addedEndpoints, agent allowedProviders). + */ +export default function useProviderKeys(): string[] { + const { data: endpointsConfig } = useGetEndpointsQuery(); + const { data: startupConfig } = useGetStartupConfig(); + const hasAgentAccess = useHasAccess({ + permissionType: PermissionTypes.AGENTS, + permission: Permissions.USE, + }); + + return useMemo( + () => + getUserKeyEndpoints({ + endpointsConfig, + modelSpecs: startupConfig?.modelSpecs, + hasAgentAccess: hasAgentAccess === true, + }), + [endpointsConfig, startupConfig?.modelSpecs, hasAgentAccess], + ); +} diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.spec.ts b/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.spec.ts new file mode 100644 index 0000000000..7bc17f0e55 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.spec.ts @@ -0,0 +1,105 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TConfig, TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; +import { getUserKeyEndpoints, isUserProvidedEndpointConfig } from './utils'; + +const cfg = (overrides: Partial = {}): TConfig => ({ order: 0, ...overrides }); + +const spec = (endpoint: string): TModelSpec => ({ + name: endpoint, + label: endpoint, + preset: { endpoint }, +}); + +const endpointsConfig: TEndpointsConfig = { + openAI: cfg({ userProvide: true }), + anthropic: cfg({ userProvide: true }), + google: cfg({ userProvide: false }), + bedrock: cfg({ userProvideBearerToken: true }), + [EModelEndpoint.agents]: cfg({ allowedProviders: ['anthropic'] }), +}; + +describe('isUserProvidedEndpointConfig', () => { + it('returns false for nullish config', () => { + expect(isUserProvidedEndpointConfig(null)).toBe(false); + expect(isUserProvidedEndpointConfig(undefined)).toBe(false); + }); + + it('returns true for an API key endpoint', () => { + expect(isUserProvidedEndpointConfig(cfg({ userProvide: true }))).toBe(true); + }); + + it('returns true for a Bedrock credential endpoint', () => { + expect(isUserProvidedEndpointConfig(cfg({ userProvideSecretAccessKey: true }))).toBe(true); + }); + + it('returns false for a user-provided base URL alone', () => { + expect(isUserProvidedEndpointConfig(cfg({ userProvideURL: true }))).toBe(false); + }); + + it('returns false when no credential is user-provided', () => { + expect(isUserProvidedEndpointConfig(cfg())).toBe(false); + }); +}); + +describe('getUserKeyEndpoints', () => { + it('returns an empty list when endpoints have not loaded', () => { + expect(getUserKeyEndpoints({ endpointsConfig: undefined, hasAgentAccess: true })).toEqual([]); + }); + + it('lists every user-provided endpoint when no modelSpecs are configured', () => { + expect(getUserKeyEndpoints({ endpointsConfig, hasAgentAccess: true })).toEqual([ + 'openAI', + 'anthropic', + 'bedrock', + ]); + }); + + it('limits to endpoints referenced by modelSpecs', () => { + const result = getUserKeyEndpoints({ + endpointsConfig, + modelSpecs: { list: [spec('openAI')] }, + hasAgentAccess: true, + }); + expect(result).toEqual(['openAI']); + }); + + it('includes addedEndpoints alongside spec endpoints', () => { + const result = getUserKeyEndpoints({ + endpointsConfig, + modelSpecs: { list: [spec('openAI')], addedEndpoints: ['bedrock'] }, + hasAgentAccess: true, + }); + expect(result).toEqual(['openAI', 'bedrock']); + }); + + it('expands a reachable agents endpoint to its allowedProviders', () => { + const result = getUserKeyEndpoints({ + endpointsConfig, + modelSpecs: { list: [spec('openAI')], addedEndpoints: [EModelEndpoint.agents] }, + hasAgentAccess: true, + }); + expect(result).toEqual(['openAI', 'anthropic']); + }); + + it('expands a reachable agents endpoint with no allowedProviders to all providers', () => { + const unrestricted: TEndpointsConfig = { + ...endpointsConfig, + [EModelEndpoint.agents]: cfg(), + }; + const result = getUserKeyEndpoints({ + endpointsConfig: unrestricted, + modelSpecs: { list: [spec('openAI')], addedEndpoints: [EModelEndpoint.agents] }, + hasAgentAccess: true, + }); + expect(result).toEqual(['openAI', 'anthropic', 'bedrock']); + }); + + it('does not expand agent providers when the user lacks agent access', () => { + const result = getUserKeyEndpoints({ + endpointsConfig, + modelSpecs: { list: [spec('openAI')], addedEndpoints: [EModelEndpoint.agents] }, + hasAgentAccess: false, + }); + expect(result).toEqual(['openAI']); + }); +}); diff --git a/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.ts b/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.ts new file mode 100644 index 0000000000..ed28fb91df --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/ProviderKeys/utils.ts @@ -0,0 +1,99 @@ +import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import type { TConfig, TSpecsConfig, TEndpointsConfig } from 'librechat-data-provider'; + +/** + * Whether an endpoint config requires a user-provided credential — an API key or + * any Bedrock credential field. `userProvideURL` alone is excluded: a user-provided + * base URL does not imply a user-managed key. + */ +export const isUserProvidedEndpointConfig = (config?: TConfig | null): boolean => { + if (!config) { + return false; + } + return ( + !!config.userProvide || + !!config.userProvideAccessKeyId || + !!config.userProvideSecretAccessKey || + !!config.userProvideSessionToken || + !!config.userProvideBearerToken + ); +}; + +/** + * The endpoints a user can actually reach, mirroring the model selector and mention + * popover. With no modelSpecs every configured endpoint is reachable. When modelSpecs + * are configured the catalog is limited to the endpoints referenced by the specs plus + * `modelSpecs.addedEndpoints`; if agents are reachable the set additionally includes + * the agent `allowedProviders` (all providers when left unrestricted). + */ +const getReachableEndpoints = ({ + endpointsConfig, + modelSpecs, + hasAgentAccess, +}: { + endpointsConfig: NonNullable; + modelSpecs?: Pick | null; + hasAgentAccess: boolean; +}): Set => { + const allEndpoints = Object.keys(endpointsConfig); + const specs = modelSpecs?.list ?? []; + if (specs.length === 0) { + return new Set(allEndpoints); + } + + const reachable = new Set(); + for (const spec of specs) { + const specEndpoint = spec.preset?.endpoint; + if (specEndpoint) { + reachable.add(specEndpoint); + } + } + for (const endpoint of modelSpecs?.addedEndpoints ?? []) { + reachable.add(endpoint); + } + + if (!hasAgentAccess || !reachable.has(EModelEndpoint.agents)) { + return reachable; + } + + const allowedProviders = endpointsConfig[EModelEndpoint.agents]?.allowedProviders ?? []; + if (allowedProviders.length > 0) { + for (const provider of allowedProviders) { + reachable.add(provider); + } + return reachable; + } + + for (const endpoint of allEndpoints) { + if (!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)) { + reachable.add(endpoint); + } + } + return reachable; +}; + +/** + * Reachable endpoints that require a user-provided credential — the endpoints whose + * keys can be set or rotated from the API keys settings section. + */ +export const getUserKeyEndpoints = ({ + endpointsConfig, + modelSpecs, + hasAgentAccess, +}: { + endpointsConfig?: TEndpointsConfig | null; + modelSpecs?: Pick | null; + hasAgentAccess: boolean; +}): string[] => { + if (!endpointsConfig) { + return []; + } + const reachable = getReachableEndpoints({ endpointsConfig, modelSpecs, hasAgentAccess }); + const result: string[] = []; + for (const [endpoint, config] of Object.entries(endpointsConfig)) { + if (reachable.has(endpoint) && isUserProvidedEndpointConfig(config)) { + result.push(endpoint); + } + } + return result; +}; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 771055827d..5e81316ade 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1427,6 +1427,8 @@ "com_ui_prompts_allow_share_public": "Allow sharing Prompts publicly", "com_ui_prompts_allow_use": "Allow using Prompts", "com_ui_provider": "Provider", + "com_ui_provider_api_keys_description": "Manage API keys for endpoints configured to use a user-provided key.", + "com_ui_provider_api_keys_not_set": "No key set", "com_ui_quality": "Quality", "com_ui_ran_n_agents": "Ran {{0}} agents", "com_ui_read_aloud": "Read aloud", @@ -1562,6 +1564,7 @@ "com_ui_settings_label_import": "Import conversations", "com_ui_settings_label_language_stt": "Speech-to-text language", "com_ui_settings_label_playback_rate": "Playback rate", + "com_ui_settings_label_provider_api_keys": "Provider API keys", "com_ui_settings_label_revoke_keys": "Revoke keys", "com_ui_settings_label_shared_links": "Shared links", "com_ui_settings_label_voice": "Voice",