🔑 feat: Surface User-Provided API Keys in Settings, Scoped to Reachable Endpoints (#13864)

Adds a "Provider API keys" entry under Settings → Data controls → API keys
that lists every endpoint requiring a user-provided credential and lets users
set or rotate its key via SetKeyDialog. This is always reachable, so keys can
be managed even when `interface.modelSelect` is hidden by `modelSpecs`.

The endpoint list is filtered the same way the mention popover and model
selector menu are:
- No modelSpecs → every user-provided endpoint.
- modelSpecs configured → limited to spec endpoints ∪ `modelSpecs.addedEndpoints`.
- agents reachable (with access) → expanded to the agents `allowedProviders`
  (all providers when unrestricted).

Reworks #13303 onto the registry-driven Settings dialog (#13722); the prior
standalone tab and the `APIKeys` directory are superseded (the latter also
collided with the agent `ApiKeys` feature from #13819).
This commit is contained in:
Danny Avila 2026-06-20 10:54:30 -04:00 • committed by GitHub
parent 21d98b85bd
commit 9dd0df9d61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 403 additions and 0 deletions

View file

@ -10,6 +10,7 @@ const ctx: SettingsContextValue = {
hasAnyPersonalizationFeature: false,
hasMemoryOptOut: false,
hasRemoteAgents: false,
hasUserProvidedEndpoints: false,
hasMultiConvo: false,
hasPrompts: false,
isLocalProvider: true,

View file

@ -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<string>(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,

View file

@ -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,

View file

@ -38,6 +38,7 @@ export interface SettingsContextValue {
hasAnyPersonalizationFeature: boolean;
hasMemoryOptOut: boolean;
hasRemoteAgents: boolean;
hasUserProvidedEndpoints: boolean;
hasMultiConvo: boolean;
hasPrompts: boolean;
isLocalProvider: boolean;

View file

@ -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 (
<>
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 items-center gap-3">
{Icon && (
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
{React.createElement(Icon, {
size: 20,
className: 'text-text-primary shrink-0 icon-md',
iconURL,
endpoint,
})}
</div>
)}
<div className="min-w-0">
<div className="truncate font-medium text-text-primary">{label}</div>
<div className="truncate text-xs text-text-secondary">{expiryLabel}</div>
</div>
</div>
<Button variant="outline" onClick={() => setDialogOpen(true)}>
{hasKey ? localize('com_ui_update') : localize('com_endpoint_config_key')}
</Button>
</div>
{dialogOpen && (
<SetKeyDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
endpoint={endpoint}
endpointType={endpointType}
userProvideURL={getEndpointField(endpointsConfig, endpoint, 'userProvideURL')}
userProvideAccessKeyId={getEndpointField(
endpointsConfig,
endpoint,
'userProvideAccessKeyId',
)}
userProvideSecretAccessKey={getEndpointField(
endpointsConfig,
endpoint,
'userProvideSecretAccessKey',
)}
userProvideSessionToken={getEndpointField(
endpointsConfig,
endpoint,
'userProvideSessionToken',
)}
userProvideBearerToken={getEndpointField(
endpointsConfig,
endpoint,
'userProvideBearerToken',
)}
/>
)}
</>
);
}

View file

@ -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 (
<div className="flex items-center justify-between">
<Label id="provider-api-keys-label">
{localize('com_ui_settings_label_provider_api_keys')}
</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button variant="outline" aria-labelledby="provider-api-keys-label">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogContent
className="w-11/12 max-w-2xl bg-background text-text-primary shadow-2xl"
aria-describedby={undefined}
>
<OGDialogHeader className="space-y-0 pr-8 text-left">
<div className="flex items-center gap-1.5">
<OGDialogTitle>{localize('com_ui_settings_label_provider_api_keys')}</OGDialogTitle>
<InfoHoverCard text={localize('com_ui_provider_api_keys_description')} />
</div>
</OGDialogHeader>
{endpointsConfig && (
<div className="divide-y divide-border-light">
{endpoints.map((endpoint) => (
<ProviderKeyRow
key={endpoint}
endpoint={endpoint}
endpointsConfig={endpointsConfig}
/>
))}
</div>
)}
</OGDialogContent>
</OGDialog>
</div>
);
}

View file

@ -0,0 +1 @@
export { default } from './ProviderKeys';

View file

@ -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],
);
}

View file

@ -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> = {}): 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']);
});
});

View file

@ -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<TEndpointsConfig>;
modelSpecs?: Pick<TSpecsConfig, 'list' | 'addedEndpoints'> | null;
hasAgentAccess: boolean;
}): Set<string> => {
const allEndpoints = Object.keys(endpointsConfig);
const specs = modelSpecs?.list ?? [];
if (specs.length === 0) {
return new Set(allEndpoints);
}
const reachable = new Set<string>();
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<TSpecsConfig, 'list' | 'addedEndpoints'> | 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;
};

View file

@ -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",