@@ -238,7 +284,7 @@ export function FieldRenderer({
return (
- {managedRoot && managedNotice(group.path)}
+ {noticeRun && managedNotice(noticeRun)}
{single}
);
diff --git a/src/components/configuration/renderer/__tests__/controls.test.tsx b/src/components/configuration/renderer/__tests__/controls.test.tsx
index e633c2e6..24ddb493 100644
--- a/src/components/configuration/renderer/__tests__/controls.test.tsx
+++ b/src/components/configuration/renderer/__tests__/controls.test.tsx
@@ -532,4 +532,62 @@ describe('YAML-managed notice', () => {
);
expect(screen.getAllByText('com_config_yaml_managed_field')).toHaveLength(1);
});
+
+ it('says a setting LibreChat reads only at startup never takes an override, and disables it', () => {
+ const backgroundTasks = createField({
+ key: 'backgroundTasks',
+ type: 'object',
+ isObject: true,
+ path: 'endpoints.agents.backgroundTasks',
+ children: [
+ createField({
+ key: 'completionResultBatchSize',
+ type: 'number',
+ path: 'endpoints.agents.backgroundTasks.completionResultBatchSize',
+ }),
+ createField({
+ key: 'completionResultMaxChars',
+ type: 'number',
+ path: 'endpoints.agents.backgroundTasks.completionResultMaxChars',
+ }),
+ ],
+ });
+ render(
+
,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /backgroundTasks/ }));
+ expect(screen.getAllByText('com_config_startup_only_field:{"count":1}')).toHaveLength(1);
+ expect(screen.queryByText('com_config_yaml_managed_field')).not.toBeInTheDocument();
+ });
+
+ it('shows one notice above consecutive startup-only settings', () => {
+ const number = (key: string) =>
+ createField({ key, type: 'number', path: `mcpSettings.catalogRecovery.${key}` });
+ render(
+
,
+ );
+ expect(
+ screen.getAllByText(/^com_config_startup_only_field/).map((el) => el.textContent),
+ ).toEqual([
+ 'com_config_startup_only_field:{"count":2}',
+ 'com_config_startup_only_field:{"count":1}',
+ ]);
+ });
});
diff --git a/src/components/configuration/review/diff.ts b/src/components/configuration/review/diff.ts
index dee68c97..5b1e5dbb 100644
--- a/src/components/configuration/review/diff.ts
+++ b/src/components/configuration/review/diff.ts
@@ -76,6 +76,21 @@ export function isSecretPath(path: string): boolean {
return getFieldMeta(path)?.secret === true;
}
+/** How the metadata registry names any key of a record. */
+const RECORD_KEY = '{}';
+
+/**
+ * Whether the value at `segments` must never be shown (`isSecretPath`). A
+ * segment with a dot is a record key (a header, an inference profile ID), a
+ * free name rather than a field's, so it is read as any key of its record:
+ * its text (`tenant.apiKey`) never makes the value a secret.
+ */
+function isSecretSegments(segments: readonly string[]): boolean {
+ return isSecretPath(
+ segments.map((segment) => (segment.includes('.') ? RECORD_KEY : segment)).join('.'),
+ );
+}
+
export function isObjectList(value: t.ConfigValue): value is t.ConfigRecord[] {
return Array.isArray(value) && value.length > 0 && value.every(isConfigRecord);
}
@@ -438,6 +453,25 @@ function diffPositionalList(
return items;
}
+/**
+ * Items of the value under `key` of the object at `path`. A key with a dot
+ * (`us.anthropic.claude-sonnet-4-6`) reads as several keys in a dot-path, so
+ * these items carry their path's segments, the key kept whole, and a secret
+ * read from the dot-path is checked again by them (`isSecretSegments`). Items
+ * under a dotted key nested deeper already carry theirs: only the part up to
+ * this key is replaced.
+ */
+function underKey(items: t.ChangeSummaryItem[], path: string, key: string): t.ChangeSummaryItem[] {
+ if (!key.includes('.')) return items;
+ const parent = path.split('.');
+ const depth = parent.length + key.split('.').length;
+ return items.map((item) => {
+ const segments = [...parent, key, ...(item.segments ?? item.path.split('.')).slice(depth)];
+ if (!item.secret) return { ...item, segments };
+ return { ...item, segments, secret: isSecretSegments(segments) || undefined };
+ });
+}
+
/** Entries of a record collection (`mcpServers`) saved as a whole, matched by key. */
function diffRecordCollection(
path: string,
@@ -452,9 +486,31 @@ function diffRecordCollection(
entryPath: `${path}.${key}`,
identity: key,
};
- if (before[key] == null) return [entryItem('added', entryRef, undefined, after[key], ctx)];
- if (after[key] == null) return [entryItem('removed', entryRef, before[key], undefined, ctx)];
- return diffValue(entryRef.entryPath, before[key], after[key], ctx, entryRef);
+ if (before[key] == null) {
+ return underKey([entryItem('added', entryRef, undefined, after[key], ctx)], path, key);
+ }
+ if (after[key] == null) {
+ return underKey([entryItem('removed', entryRef, before[key], undefined, ctx)], path, key);
+ }
+ return underKey(
+ diffValue(entryRef.entryPath, before[key], after[key], ctx, entryRef),
+ path,
+ key,
+ );
+ });
+}
+
+/** Leaves of an object set where nothing was, each a row (`flattenObject`, dotted keys kept whole). */
+function addedLeaves(
+ path: string,
+ after: t.ConfigRecord,
+ ctx: DiffContext,
+ entry: t.ChangeEntryRef | undefined,
+): t.ChangeSummaryItem[] {
+ return Object.entries(after).flatMap(([key, value]) => {
+ const leaves = Object.entries(flattenObject({ [key]: value }, path));
+ const items = leaves.map(([leafPath, leaf]) => leafItem(leafPath, undefined, leaf, ctx, entry));
+ return underKey(items, path, key);
});
}
@@ -495,12 +551,13 @@ function diffValue(
}
if (isConfigRecord(after) && isConfigRecord(before)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
- return keys.flatMap((key) => diffValue(`${path}.${key}`, before[key], after[key], ctx, entry));
+ return keys.flatMap((key) =>
+ underKey(diffValue(`${path}.${key}`, before[key], after[key], ctx, entry), path, key),
+ );
}
if (isConfigRecord(after) && before == null) {
if (isEntryPath && entry) return [entryItem('added', entry, undefined, after, ctx)];
- const leaves = Object.entries(flattenObject(after, path));
- return leaves.map(([leafPath, value]) => leafItem(leafPath, undefined, value, ctx, entry));
+ return addedLeaves(path, after, ctx, entry);
}
if (after == null && isEntryPath && entry) {
return [entryItem('removed', entry, before, undefined, ctx)];
diff --git a/src/components/configuration/review/revert.ts b/src/components/configuration/review/revert.ts
index 16c329a2..b90e8246 100644
--- a/src/components/configuration/review/revert.ts
+++ b/src/components/configuration/review/revert.ts
@@ -16,6 +16,21 @@ function relativeSegments(path: string, base: string): string[] {
return path === base ? [] : path.slice(base.length + 1).split('.');
}
+/**
+ * The segments of a row's path below `base` (a prefix of it). A dotted record
+ * key (`us.anthropic.claude-sonnet-4-6`) stays one segment: the row carries
+ * its path's `segments`, since splitting the path would break the key apart.
+ */
+function segmentsBelow(item: t.ChangeSummaryItem, base: string): string[] {
+ if (!item.segments) return relativeSegments(item.path, base);
+ let prefix = '';
+ for (let i = 0; i < item.segments.length; i++) {
+ prefix = i === 0 ? item.segments[i] : `${prefix}.${item.segments[i]}`;
+ if (prefix === base) return item.segments.slice(i + 1);
+ }
+ return relativeSegments(item.path, base);
+}
+
/**
* `pending` with the value at `segments` put back to the one in `baseline`
* (removed when the baseline has none). Where the pending value is shaped
@@ -97,7 +112,7 @@ function revertListEntry(
next[index] = revertAt(pending[index], previous, [key]);
return next;
}
- next[index] = revertAt(pending[index], previous, relativeSegments(item.path, entryPath));
+ next[index] = revertAt(pending[index], previous, segmentsBelow(item, entryPath));
return next;
}
@@ -113,7 +128,7 @@ function revertWithin(
item: t.ChangeSummaryItem,
): t.ConfigValue {
const { collectionPath } = item;
- const byPath = () => revertAt(pending, baseline, relativeSegments(item.path, sourcePath));
+ const byPath = () => revertAt(pending, baseline, segmentsBelow(item, sourcePath));
if (
collectionPath == null ||
(collectionPath !== sourcePath && !collectionPath.startsWith(`${sourcePath}.`))
diff --git a/src/components/configuration/review/review.test.ts b/src/components/configuration/review/review.test.ts
index 4873b267..80f2dd28 100644
--- a/src/components/configuration/review/review.test.ts
+++ b/src/components/configuration/review/review.test.ts
@@ -762,6 +762,145 @@ describe('planRevert', () => {
value: { ...zai, apiKey: 'k9' },
});
});
+
+ describe('dotted record keys', () => {
+ const PROFILES = 'endpoints.bedrock.inferenceProfiles';
+ const US = 'us.anthropic.claude-sonnet-4-6';
+ const GLOBAL = 'global.anthropic.claude-opus-5-5';
+ const pairs = (record: Record
) =>
+ Object.entries(record).map(([key, value]) => ({ key, value }));
+ const withProfiles = (profiles?: t.ConfigRecord): t.ConfigRecord =>
+ profiles ? { endpoints: { bedrock: { inferenceProfiles: profiles } } } : {};
+
+ function rowsOf(profiles: t.ConfigRecord | undefined, pending: Record) {
+ const tree = withProfiles(profiles);
+ const saves = [{ fieldPath: PROFILES, value: pending }];
+ const items = summarizeChanges(makeInput({ baseline: tree, saves, isOwned: () => true }));
+ return { tree, items, edited: { [PROFILES]: pairs(pending) } };
+ }
+
+ it('keeps the key whole in the row, so Revert drops only that added key', () => {
+ const pending = { plainA: 'arn:a', [US]: 'arn:us', [GLOBAL]: 'arn:global' };
+ const { tree, items, edited } = rowsOf({ plainA: 'arn:a' }, pending);
+ expect(items.map((item) => item.path)).toEqual([
+ `${PROFILES}.${US}`,
+ `${PROFILES}.${GLOBAL}`,
+ ]);
+ const us = items[0];
+ expect(us.segments).toEqual(['endpoints', 'bedrock', 'inferenceProfiles', US]);
+ expect(planRevert(us, edited, tree)).toEqual({
+ op: 'set',
+ path: PROFILES,
+ value: { plainA: 'arn:a', [GLOBAL]: 'arn:global' },
+ });
+ });
+
+ it('puts a removed stored key back and keeps the other change', () => {
+ const pending = { [US]: 'arn:us2' };
+ const { tree, items, edited } = rowsOf({ [US]: 'arn:us', [GLOBAL]: 'arn:global' }, pending);
+ const removed = items.find((item) => item.kind === 'removed')!;
+ expect(removed.segments).toEqual(['endpoints', 'bedrock', 'inferenceProfiles', GLOBAL]);
+ expect(planRevert(removed, edited, tree)).toEqual({
+ op: 'set',
+ path: PROFILES,
+ value: { [US]: 'arn:us2', [GLOBAL]: 'arn:global' },
+ });
+ const changed = items.find((item) => item.kind === 'changed')!;
+ expect(planRevert(changed, edited, tree)).toEqual({
+ op: 'set',
+ path: PROFILES,
+ value: { [US]: 'arn:us' },
+ });
+ });
+
+ it('reverts a key of a record set where nothing was stored', () => {
+ const pending = { [US]: 'arn:us', [GLOBAL]: 'arn:global' };
+ const { tree, items, edited } = rowsOf(undefined, pending);
+ const global = items.find((item) => item.path === `${PROFILES}.${GLOBAL}`)!;
+ expect(global).toMatchObject({ kind: 'added', segments: [...PROFILES.split('.'), GLOBAL] });
+ expect(planRevert(global, edited, tree)).toEqual({
+ op: 'set',
+ path: PROFILES,
+ value: { [US]: 'arn:us' },
+ });
+ });
+
+ it('reverts a dotted header of a list entry and keeps its other change', () => {
+ const team = { ...moonshot, headers: { 'x-team': 'eng' } };
+ const tree = { ...baseline, endpoints: { custom: [team, zai, vercel] } };
+ const edited = {
+ ...team,
+ modelDisplayLabel: 'Kimi',
+ headers: { 'x-team': 'eng', 'x-portkey.config': 'cfg' },
+ };
+ const pending = [edited, zai, vercel];
+ const items = summarizeChanges(makeInput({ baseline: tree, saves: saveList(pending) }));
+ const header = items.find(
+ (item) => item.path === 'endpoints.custom.0.headers.x-portkey.config',
+ )!;
+ expect(header.segments).toEqual(['endpoints', 'custom', '0', 'headers', 'x-portkey.config']);
+ expect(planRevert(header, { 'endpoints.custom': pending }, tree)).toEqual({
+ op: 'set',
+ path: 'endpoints.custom',
+ value: [{ ...team, modelDisplayLabel: 'Kimi' }, zai, vercel],
+ });
+ });
+
+ it('shows a dotted key whose text ends in a secret name as the value it is', () => {
+ const header = 'tenant.apiKey';
+ const team = { ...moonshot, headers: { [header]: 'acme' } };
+ const tree = { ...baseline, endpoints: { custom: [team, zai, vercel] } };
+ const changed = { ...team, headers: { [header]: 'globex' } };
+ const added = { ...zai, headers: { [header]: 'initech' } };
+ const items = summarizeChanges(
+ makeInput({ baseline: tree, saves: saveList([changed, added, vercel]) }),
+ );
+ expect(items).toHaveLength(2);
+ expect(items[0]).toMatchObject({ kind: 'changed', before: 'acme', after: 'globex' });
+ expect(items[1]).toMatchObject({ kind: 'added', after: 'initech' });
+ for (const item of items) {
+ expect(item.segments?.at(-1)).toBe(header);
+ expect(item.secret).toBeUndefined();
+ }
+ const profiles = summarizeChanges(
+ makeInput({
+ baseline: withProfiles({ 'us.secretKey': 'arn:a' }),
+ saves: [{ fieldPath: PROFILES, value: { 'us.secretKey': 'arn:b' } }],
+ isOwned: () => true,
+ }),
+ );
+ expect(profiles).toEqual([
+ expect.objectContaining({ kind: 'changed', before: 'arn:a', after: 'arn:b' }),
+ ]);
+ expect(profiles[0].secret).toBeUndefined();
+ });
+
+ it('still hides a secret field under a dotted key', () => {
+ const before = { 'team.one': { apiKey: 'sk-old' } };
+ const after = { 'team.one': { apiKey: 'sk-new' } };
+ const [item] = summarizeChanges(
+ makeInput({ baseline: { vaults: before }, saves: [{ fieldPath: 'vaults', value: after }] }),
+ );
+ expect(item).toMatchObject({
+ path: 'vaults.team.one.apiKey',
+ segments: ['vaults', 'team.one', 'apiKey'],
+ secret: true,
+ });
+ });
+
+ it('restores a dotted entry name of a record collection saved as a whole', () => {
+ const docs = { type: 'http', url: 'https://docs.example/mcp' };
+ const tree = { mcpServers: { ...baseline.mcpServers, 'my.server': docs } };
+ const pending = { ...baseline.mcpServers };
+ const saves = [{ fieldPath: 'mcpServers', value: pending }];
+ const [removed] = summarizeChanges(makeInput({ baseline: tree, saves }));
+ expect(removed).toMatchObject({ kind: 'removed', segments: ['mcpServers', 'my.server'] });
+ expect(planRevert(removed, { mcpServers: pending }, tree)).toEqual({
+ op: 'discard',
+ paths: ['mcpServers'],
+ });
+ });
+ });
});
describe('formatReviewValue', () => {
diff --git a/src/components/configuration/sections/LangfuseRenderer.tsx b/src/components/configuration/sections/LangfuseRenderer.tsx
index 4eb5c062..ad9ea275 100644
--- a/src/components/configuration/sections/LangfuseRenderer.tsx
+++ b/src/components/configuration/sections/LangfuseRenderer.tsx
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Select, TextField } from '@clickhouse/click-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import type { LangfuseConnectionStatus } from '@/server';
+import type { TLangfuseConnectionStatus } from 'librechat-data-provider';
import type * as t from '@/types';
import {
getLangfuseConnectionFn,
@@ -14,7 +14,7 @@ import { useLocalize } from '@/hooks';
type VerificationState = 'idle' | 'unverified' | 'checking' | 'verified' | 'failed';
-function getConnectionKey(status?: LangfuseConnectionStatus): string | undefined {
+function getConnectionKey(status?: TLangfuseConnectionStatus): string | undefined {
if (!status?.configured || !status.destination || !status.publicKey) return undefined;
return `${status.destination}\u0000${status.publicKey}`;
}
@@ -60,7 +60,7 @@ function getVerificationDotClass(state: VerificationState): string {
export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
- const [status, setStatus] = useState();
+ const [status, setStatus] = useState();
const [destination, setDestination] = useState('');
const [publicKey, setPublicKey] = useState('');
const [secretKey, setSecretKey] = useState('');
@@ -264,7 +264,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr
const handleCancel = () => {
hasDraftRef.current = false;
const latestStatus =
- queryClient.getQueryData(LANGFUSE_CONNECTION_QUERY_KEY) ?? status;
+ queryClient.getQueryData(LANGFUSE_CONNECTION_QUERY_KEY) ?? status;
setStatus(latestStatus);
const storedDestination = latestStatus?.destination;
setDestination(storedDestination ?? '');
@@ -395,7 +395,7 @@ export function LangfuseRenderer({ disabled, isEditingScope }: t.FieldRendererPr
}}
aria-label={`${localize('com_ui_edit')} ${localize('com_config_langfuse_secret_key')}`}
>
- {status?.displaySecretKey}
+ {status?.secretKeyPreview}
) : (
({
useLocalize: () => (key: string) => key,
@@ -152,7 +152,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-1234567890abcdef',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
});
renderLangfuse();
@@ -173,7 +173,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-1234567890abcdef',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
});
renderLangfuse({ disabled: true });
@@ -190,11 +190,13 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'removed-region',
publicKey: 'pk-lf-1234567890abcdef',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
});
renderLangfuse();
- expect(await screen.findByRole('button', { name: 'com_config_langfuse_disable' })).toBeVisible();
+ expect(
+ await screen.findByRole('button', { name: 'com_config_langfuse_disable' }),
+ ).toBeVisible();
expect(
screen.queryByRole('button', { name: 'com_config_langfuse_save_and_enable' }),
).not.toBeInTheDocument();
@@ -207,7 +209,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-new',
- displaySecretKey: 'sk-lf-...cret',
+ secretKeyPreview: 'sk-lf-...cret',
});
renderLangfuse();
@@ -250,7 +252,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-old',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
mockUpdate.mockResolvedValue({ ...configuredStatus, publicKey: 'pk-lf-new' });
@@ -292,7 +294,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-stored',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
const { queryClient } = renderLangfuse();
@@ -327,7 +329,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-stored',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
mockTest.mockRejectedValueOnce(new Error('Langfuse is temporarily unavailable'));
@@ -384,7 +386,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-existing',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: false });
@@ -411,7 +413,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-existing',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: true });
@@ -440,7 +442,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-existing',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
mockGet.mockResolvedValue(configuredStatus);
mockUpdate.mockResolvedValue({ ...configuredStatus, enabled: true, publicKey: 'pk-lf-new' });
@@ -469,7 +471,7 @@ describe('LangfuseRenderer', () => {
destinations,
destination: 'eu',
publicKey: 'pk-lf-existing',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
});
renderLangfuse();
await screen.findByText('com_config_langfuse_verified');
diff --git a/src/components/configuration/sections/features/Credential.tsx b/src/components/configuration/sections/features/Credential.tsx
index de12928e..7d894546 100644
--- a/src/components/configuration/sections/features/Credential.tsx
+++ b/src/components/configuration/sections/features/Credential.tsx
@@ -2,10 +2,10 @@ import { useId } from 'react';
import type * as t from '@/types';
import { SecretField, SecretInput } from '../../fields/SecretField';
import { FieldControl } from '../../renderer/FieldControl';
+import { useLocalize, useConfigFeatures } from '@/hooks';
import { DecorativeIcon } from '@/components/shared';
import { TextField } from '../../fields/TextField';
import { credentialState } from './state';
-import { useLocalize } from '@/hooks';
import { cn } from '@/utils';
/** `${SERPER_API_KEY}`: how librechat.yaml references the credential's environment variable. */
@@ -19,14 +19,29 @@ const TONE_CLASSES: Record = {
warn: 'text-(--settings-warn-fg)',
};
-/** The status line of a credential: its source, and whether something is wrong. */
+/** States in which the credential's value is the one typed or stored here, not an environment reference. */
+const LITERAL_STATES: ReadonlySet = new Set(['pending', 'stored', 'set']);
+
+/**
+ * The status line of a credential: its source, and whether something is
+ * wrong. Before `webSearchStoredKeys`, LibreChat resolves web search values
+ * only from `${VAR}` references, so a value typed or stored here is ignored.
+ */
function statusLine(
state: t.CredentialState,
credential: t.WebSearchCredential,
serviceName: string,
inUse: boolean,
+ storedKeysUsed: boolean,
localize: t.LocalizeFn,
): t.CredentialStatusLine | null {
+ if (!storedKeysUsed && LITERAL_STATES.has(state.status)) {
+ return {
+ tone: 'warn',
+ icon: 'warning',
+ text: localize('com_config_features_cred_env_only', { ref: envRef(credential) }),
+ };
+ }
switch (state.status) {
case 'pending':
return credential.encrypted
@@ -88,10 +103,11 @@ export function CredentialControl({
...props
}: t.CredentialControlProps) {
const localize = useLocalize();
+ const { webSearchStoredKeys } = useConfigFeatures();
const statusId = useId();
if (props.disabled) return ;
const state = credentialState({ ...props, credential });
- const line = statusLine(state, credential, serviceName, inUse, localize);
+ const line = statusLine(state, credential, serviceName, inUse, webSearchStoredKeys, localize);
const text = typeof props.value === 'string' ? props.value : '';
const placeholder = localize(
credential.secret
diff --git a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
index 3fd42c4a..68845d4d 100644
--- a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
+++ b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
@@ -95,12 +95,17 @@ describe('WebSearchRenderer', () => {
});
it('masks a typed key and says it will be stored encrypted', () => {
- renderSection(WebSearchRenderer, { section: 'webSearch', yaml: YAML });
+ renderSection(WebSearchRenderer, {
+ section: 'webSearch',
+ yaml: YAML,
+ features: { webSearchStoredKeys: true },
+ });
const key = screen.getByLabelText('Cohere API key');
fireEvent.change(key, { target: { value: 'co-secret-1234567890' } });
expect(key).toHaveAttribute('type', 'password');
fireEvent.blur(key);
expect(screen.getByText('Will be stored encrypted when you save.')).toBeInTheDocument();
+ expect(screen.queryByText(/environment variable references/)).not.toBeInTheDocument();
});
it('shows a stored key only as its masked preview, with Replace', () => {
@@ -108,6 +113,7 @@ describe('WebSearchRenderer', () => {
section: 'webSearch',
yaml: YAML,
overrides: { webSearch: { cohereApiKeyPreview: '...7890' } },
+ features: { webSearchStoredKeys: true },
});
expect(screen.getByDisplayValue('...7890')).toBeDisabled();
expect(screen.getByRole('button', { name: /Replace/ })).toBeInTheDocument();
@@ -116,6 +122,37 @@ describe('WebSearchRenderer', () => {
).toBeInTheDocument();
});
+ describe('on a server that reads web search values only from ${VAR} references', () => {
+ const ENV_ONLY = /only from environment variable references such as \$\{COHERE_API_KEY\}/;
+
+ it('warns that a typed key is ignored', () => {
+ renderSection(WebSearchRenderer, { section: 'webSearch', yaml: YAML });
+ const key = screen.getByLabelText('Cohere API key');
+ fireEvent.change(key, { target: { value: 'co-secret-1234567890' } });
+ fireEvent.blur(key);
+ expect(screen.getByText(ENV_ONLY)).toBeInTheDocument();
+ expect(screen.queryByText('Will be stored encrypted when you save.')).not.toBeInTheDocument();
+ });
+
+ it('warns that a stored key is ignored', () => {
+ renderSection(WebSearchRenderer, {
+ section: 'webSearch',
+ yaml: YAML,
+ overrides: { webSearch: { cohereApiKeyPreview: '...7890' } },
+ });
+ expect(screen.getByDisplayValue('...7890')).toBeDisabled();
+ expect(screen.getByText(ENV_ONLY)).toBeInTheDocument();
+ });
+
+ it('does not warn about a ${VAR} reference', () => {
+ renderSection(WebSearchRenderer, {
+ section: 'webSearch',
+ yaml: { webSearch: { ...YAML.webSearch, cohereApiKey: '${COHERE_API_KEY}' } },
+ });
+ expect(screen.queryByText(ENV_ONLY)).not.toBeInTheDocument();
+ });
+ });
+
it('flags a required credential a selected service is missing', () => {
renderSection(WebSearchRenderer, {
section: 'webSearch',
diff --git a/src/components/configuration/sections/features/__tests__/harness.tsx b/src/components/configuration/sections/features/__tests__/harness.tsx
index 058cad5b..a988ad42 100644
--- a/src/components/configuration/sections/features/__tests__/harness.tsx
+++ b/src/components/configuration/sections/features/__tests__/harness.tsx
@@ -15,7 +15,9 @@ import {
} from '@/utils';
import { extractSchemaTree, extractSchemaDefaults, getSchemaPathSet } from '@/server/config';
import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext';
+import { ConfigContractContext } from '@/contexts/ContractContext';
import { RowFilterContext } from '@/contexts/RowFilterContext';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
const TREE = extractSchemaTree(configSchema as t.ZodSchemaLike);
const SCHEMA_PATHS = getSchemaPathSet();
@@ -41,6 +43,8 @@ interface SectionSetup {
/** Data paths the tab filter matches. */
filter?: string[];
props?: Partial;
+ /** Admin config features the server reports (none: an older server). */
+ features?: Partial;
}
function withoutKey(record: Record, key: string): Record {
@@ -78,6 +82,10 @@ export function renderSection(Component: ComponentType, se
};
const configuredPaths = new Set([...collectLeafPaths(yaml), ...collectLeafPaths(overrides)]);
const filter = setup.filter ? createPathIndex(setup.filter) : null;
+ const contract: t.ConfigContract = {
+ ...BUNDLED_CONFIG_CONTRACT,
+ features: { ...BUNDLED_CONFIG_CONTRACT.features, ...setup.features },
+ };
let newSession = () => {};
function Page() {
@@ -133,11 +141,13 @@ export function renderSection(Component: ComponentType, se
...setup.props,
};
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/components/configuration/sections/mcp/Create.tsx b/src/components/configuration/sections/mcp/Create.tsx
index 9cdacef2..fdec4ce0 100644
--- a/src/components/configuration/sections/mcp/Create.tsx
+++ b/src/components/configuration/sections/mcp/Create.tsx
@@ -19,16 +19,16 @@ import {
isConfigValueEqual,
} from '@/utils';
import { planTransportSwitch, transportLabelKey, transportOf, TRANSPORTS } from './transport';
+import { deepClone, isPlainObject, resolveEntryKey, setLeaf } from './validation';
import { ConfiguredOnlyContext } from '@/contexts/ConfiguredOnlyContext';
import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext';
import { RowProblemsContext } from '@/contexts/RowProblemsContext';
-import { deepClone, isPlainObject, setLeaf } from './validation';
+import { useLocalize, useConfigFeatures } from '@/hooks';
import { FormDialog } from '@/components/shared';
import { toFieldId } from '../../renderer/paths';
import { serverNameProblem } from './names';
import { McpServerEditor } from './Editor';
import { labelForPath } from '../../meta';
-import { useLocalize } from '@/hooks';
import { McpNote } from './Note';
/** Data path of the draft: field ids and the tri-states' names derive from it ("New server: Startup"). */
@@ -96,17 +96,27 @@ function toEntry(value: t.ConfigRecord, fields?: t.SchemaField[]): t.ConfigRecor
/**
* The label of a missing field: under a record of objects it names the
* entry ("User variables › MY_KEY › Title"), else the field ("Server URL").
+ * The entry is found among the draft's own names, so a dotted one
+ * (`MY.TOKEN`, with `dottedKeys`) is named whole.
*/
-function missingLabel(rest: string, fields: t.SchemaField[], localize: t.LocalizeFn): string {
- const [head, entryKey, ...leaf] = rest.split('.');
+function missingLabel(
+ rest: string,
+ entry: t.ConfigRecord,
+ fields: t.SchemaField[],
+ localize: t.LocalizeFn,
+): string {
+ const [head] = rest.split('.', 1);
const field = fields.find((candidate) => candidate.key === head);
- if (!isRecordOfObjects(field) || entryKey === undefined || leaf.length === 0) {
+ const located = isRecordOfObjects(field)
+ ? resolveEntryKey(rest.slice(head.length + 1), Object.keys(asConfigRecord(entry[head])))
+ : null;
+ if (!located || located.segments.length === 0) {
return labelForPath(`mcpServers.{}.${rest}`, localize);
}
return [
labelForPath(`mcpServers.{}.${head}`, localize),
- entryKey,
- labelForPath(`mcpServers.{}.${head}.{}.${leaf.join('.')}`, localize),
+ located.entryKey,
+ labelForPath(`mcpServers.{}.${head}.{}.${located.segments.join('.')}`, localize),
].join(' › ');
}
@@ -159,7 +169,7 @@ function checkDraft(
}
const rest = issue.path.slice(DRAFT_PATH.length + 1);
if (issue.code === 'required' && valueAt(entry, rest) === undefined) {
- missing.push(missingLabel(rest, fields, localize));
+ missing.push(missingLabel(rest, entry, fields, localize));
} else if (!(issue.path in errors)) {
errors[issue.path] = issue.message;
}
@@ -280,6 +290,7 @@ export function CreateMcpServerDialog({
hiddenKeys,
}: t.CreateMcpServerDialogProps) {
const localize = useLocalize();
+ const { dottedKeys } = useConfigFeatures();
const [name, setName] = useState('');
const [draft, setDraft] = useState(NEW_SERVER);
const [rowProblems, setRowProblems] = useState>(() => new Map());
@@ -306,10 +317,10 @@ export function CreateMcpServerDialog({
const shownNameProblem = trimmed === '' || nameProblem == null ? null : localize(nameProblem);
/** Rows the key/value editors flag (blank, missing, repeated or dotted keys), by field label. */
const rowsToFix = useMemo(() => {
- const unsafe = findUnsafeKeys(entry, DRAFT_PATH).map(({ path }) => toFieldId(path));
+ const unsafe = findUnsafeKeys(entry, DRAFT_PATH, dottedKeys).map(({ path }) => toFieldId(path));
const ids = new Set([...rowProblems.keys(), ...unsafe]);
return [...ids].map((fieldId) => labelOfFieldId(fieldId, localize));
- }, [entry, rowProblems, localize]);
+ }, [entry, rowProblems, dottedKeys, localize]);
const isDirty = name !== '' || !isConfigValueEqual(draft, NEW_SERVER);
const blocked =
!isInherited &&
diff --git a/src/components/configuration/sections/mcp/Editor.tsx b/src/components/configuration/sections/mcp/Editor.tsx
index 67a54d76..9143fb46 100644
--- a/src/components/configuration/sections/mcp/Editor.tsx
+++ b/src/components/configuration/sections/mcp/Editor.tsx
@@ -77,6 +77,7 @@ export function McpServerEditor({
quiet,
owned,
ownKeys,
+ isPendingWhole,
editSessionId,
}: t.McpEditorProps) {
const localize = useLocalize();
@@ -293,6 +294,7 @@ export function McpServerEditor({
onChange={onChange}
disabled={disabled}
editSessionId={editSessionId}
+ wholePending={isPendingWhole?.('customUserVars')}
/>
)}
@@ -365,10 +367,20 @@ function UrlHint({ transport }: { transport: t.McpTransport }) {
return {localize(key)};
}
+/** Keys added, changed or removed between two records. */
+function changedKeys(current: t.ConfigRecord, next: t.ConfigRecord): string[] {
+ const keys = new Set([...Object.keys(next), ...Object.keys(current)]);
+ return [...keys].filter((key) => !isConfigValueEqual(current[key], next[key]));
+}
+
/**
* The values each user enters (customUserVars), as cards keyed by variable
* name. Only the variables that change are written, so a server's override
* stays sparse; variables the layer beneath defines can't be removed (D3).
+ * A change to a variable whose name has a dot (`dottedKeys`) writes the
+ * variables as one record, since a dot-path can't name that variable; so
+ * does every change while such a write is pending (`wholePending`), which a
+ * write of one variable would replace.
*/
function UserVariables({
field,
@@ -378,6 +390,7 @@ function UserVariables({
onChange,
disabled,
editSessionId,
+ wholePending,
}: {
field: t.SchemaField;
entryPath: string;
@@ -386,16 +399,17 @@ function UserVariables({
onChange: (leaf: string, value: t.ConfigValue) => void;
disabled?: boolean;
editSessionId?: number;
+ wholePending?: boolean;
}) {
const current = asConfigRecord(value);
const write = (next: t.ConfigValue) => {
const nextRecord = asConfigRecord(next);
- for (const [key, item] of Object.entries(nextRecord)) {
- if (!isConfigValueEqual(current[key], item)) onChange(`${field.key}.${key}`, item);
- }
- for (const key of Object.keys(current)) {
- if (!Object.hasOwn(nextRecord, key)) onChange(`${field.key}.${key}`, undefined);
+ const changed = changedKeys(current, nextRecord);
+ if (wholePending || changed.some((key) => key.includes('.'))) {
+ onChange(field.key, Object.keys(nextRecord).length > 0 ? nextRecord : undefined);
+ return;
}
+ for (const key of changed) onChange(`${field.key}.${key}`, nextRecord[key]);
};
return (
diff --git a/src/components/configuration/sections/mcp/Servers.tsx b/src/components/configuration/sections/mcp/Servers.tsx
index 871e9a0d..dfe3a164 100644
--- a/src/components/configuration/sections/mcp/Servers.tsx
+++ b/src/components/configuration/sections/mcp/Servers.tsx
@@ -3,15 +3,15 @@ import { isProcessMCPServerConfig } from 'librechat-data-provider';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import type * as t from '@/types';
import { getValueAtPath, isConfigRecord, isRowVisible, asConfigRecord } from '@/utils';
+import { previewedSecret, storedSecrets, usesSecret } from './validation';
import { ConfiguredOnlyContext } from '@/contexts/ConfiguredOnlyContext';
+import { useLocalize, useValuesScope, useConfigFeatures } from '@/hooks';
import { ConfigValuesContext } from '@/contexts/ConfigValuesContext';
-import { previewedSecret, storedSecrets, usesSecret } from './validation';
import { McpDeletedRow, McpHiddenRow, McpServerCard } from './Card';
import { RowFilterContext } from '@/contexts/RowFilterContext';
import { INSPECTOR_DERIVED, transportOf } from './transport';
import { copyName, serverNameProblem } from './names';
import { buildServerList, leafEntries } from './list';
-import { useLocalize, useValuesScope } from '@/hooks';
import { CreateMcpServerDialog } from './Create';
import { sourceLabel } from '../../values';
import { McpServerEditor } from './Editor';
@@ -97,6 +97,7 @@ export function McpServersRenderer({
editSessionId,
}: t.FieldRendererProps) {
const localize = useLocalize();
+ const { dottedKeys } = useConfigFeatures();
const scope = useValuesScope();
const rowFilter = useContext(RowFilterContext);
const valuesData = useContext(ConfigValuesContext);
@@ -552,13 +553,17 @@ export function McpServersRenderer({
};
/**
- * Why a card's fields are read-only: a dotted name the admin API drops, a
+ * Why a card's fields are read-only: a dotted name (an older admin API drops
+ * it; a newer one keeps it, but a field path can't address it), a
* stdio server (LibreChat applies no override to it, so overrides stored
* anyway can only be removed), or an inherited server not overridden yet.
*/
const readOnlyNoteFor = (key: string, reason: t.McpReadOnlyReason | null, overrides: number) => {
if (reason === 'dotted') {
- return
{localize('com_config_mcp_dotted_name')};
+ const noteKey = dottedKeys
+ ? 'com_config_mcp_dotted_name_paths'
+ : 'com_config_mcp_dotted_name';
+ return
{localize(noteKey)};
}
if (reason === 'process') {
const canRemove = overrides > 0 && !disabled && !list.reverted.has(key);
@@ -727,6 +732,7 @@ export function McpServersRenderer({
quiet={badges.isNew}
owned={!lowerKeys.has(key)}
ownKeys={inScope ? ownKeysOf(key) : undefined}
+ isPendingWhole={(leaf) => edits[`${prefix}${key}.${leaf}`] !== undefined}
editSessionId={editSessionId}
/>
diff --git a/src/components/configuration/sections/mcp/__tests__/Create.test.tsx b/src/components/configuration/sections/mcp/__tests__/Create.test.tsx
new file mode 100644
index 00000000..57a4ab53
--- /dev/null
+++ b/src/components/configuration/sections/mcp/__tests__/Create.test.tsx
@@ -0,0 +1,85 @@
+import '@/locales/i18n';
+import { describe, expect, it, vi } from 'vitest';
+import { configSchema } from 'librechat-data-provider';
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import type * as t from '@/types';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
+import { extractSchemaTree } from '@/server/config';
+import { CreateMcpServerDialog } from '../Create';
+
+const fields =
+ extractSchemaTree(configSchema as t.ZodSchemaLike).find((field) => field.key === 'mcpServers')
+ ?.children ?? [];
+
+const DOTTED_KEYS: t.ConfigContract = {
+ ...BUNDLED_CONFIG_CONTRACT,
+ features: { ...BUNDLED_CONFIG_CONTRACT.features, dottedKeys: true },
+};
+
+const NONE: ReadonlySet
= new Set();
+
+describe('CreateMcpServerDialog — dotted user variable names (dottedKeys)', () => {
+ it('names a dotted variable whole in what is still needed, then creates it as one record', async () => {
+ const onCreate = vi.fn();
+ render(
+
+
+ ,
+ );
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(within(dialog).getByPlaceholderText('e.g. github or acme-docs'), {
+ target: { value: 'vars' },
+ });
+ const url = dialog.querySelector('#mcpDraft-url input, input#mcpDraft-url');
+ if (!url) throw new Error('no URL input');
+ fireEvent.change(url, { target: { value: 'https://mcp.example.com/vars' } });
+ fireEvent.blur(url);
+ fireEvent.click(within(dialog).getByRole('button', { name: /User variables/ }));
+ const vars = dialog.querySelector('#mcpDraft-customUserVars');
+ if (!vars) throw new Error('no user variables editor');
+ fireEvent.click(within(vars).getByRole('button', { name: /^Add ./ }));
+ fireEvent.change(within(vars).getByRole('textbox', { name: 'Key' }), {
+ target: { value: 'MY.TOKEN' },
+ });
+ fireEvent.click(within(vars).getByRole('button', { name: 'Add' }));
+
+ const create = within(dialog).getByRole('button', { name: 'Create' });
+ expect(create).toBeDisabled();
+ const needed = within(dialog).getByText(/Still needed:/);
+ expect(needed).toHaveTextContent(/User variables › MY\.TOKEN › /);
+ expect(needed).not.toHaveTextContent(/› MY ›/);
+
+ for (const [leaf, value] of [
+ ['title', 'Token'],
+ ['description', 'Your token'],
+ ]) {
+ const input = vars.querySelector(
+ `input[id$="-${leaf}"], [id$="-${leaf}"] input, textarea[id$="-${leaf}"]`,
+ );
+ if (!input) throw new Error(`no ${leaf} input`);
+ fireEvent.change(input, { target: { value } });
+ fireEvent.blur(input);
+ }
+ expect(create).toBeEnabled();
+ await act(async () => {
+ fireEvent.click(create);
+ });
+ expect(onCreate).toHaveBeenCalledWith(
+ 'vars',
+ expect.objectContaining({
+ customUserVars: { 'MY.TOKEN': { title: 'Token', description: 'Your token' } },
+ }),
+ );
+ });
+});
diff --git a/src/components/configuration/sections/mcp/__tests__/Servers.test.tsx b/src/components/configuration/sections/mcp/__tests__/Servers.test.tsx
index 54333385..9308be56 100644
--- a/src/components/configuration/sections/mcp/__tests__/Servers.test.tsx
+++ b/src/components/configuration/sections/mcp/__tests__/Servers.test.tsx
@@ -18,8 +18,10 @@ import {
} from '@/utils';
import { extractSchemaTree, getSchemaPathSet } from '@/server/config';
import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext';
+import { ConfigContractContext } from '@/contexts/ContractContext';
import { RowFilterContext } from '@/contexts/RowFilterContext';
import { usePendingConfigValues } from '@/hooks/config/layers';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
import { useConfigEdits } from '@/hooks/config/edits';
import { McpServersRenderer } from '../Servers';
@@ -106,6 +108,7 @@ function Harness({
showConfiguredOnly = false,
preset,
extra,
+ contract = BUNDLED_CONFIG_CONTRACT,
}: {
scope?: boolean;
scopeData?: Partial;
@@ -113,6 +116,7 @@ function Harness({
showConfiguredOnly?: boolean;
preset?: t.FlatConfigMap;
extra?: t.ConfigRecord;
+ contract?: t.ConfigContract;
}) {
const layers = useMemo(() => layersFor(scope, scopeData, extra), [scope, scopeData, extra]);
const config = layers.effective ?? {};
@@ -136,26 +140,28 @@ function Harness({
[layers, editedValues, shown, visibilityEdits],
);
return (
-
-
- deriveFieldValue(path, fallback, data)}
- onChange={edits.handleFieldChange}
- onDiscardField={edits.handleDiscardField}
- onHideField={edits.handleHideField}
- onUnhideField={edits.handleUnhideField}
- onRenameEntry={edits.handleRenameEntry}
- editedValues={editedValues}
- yamlBaseKeys={new Set(Object.keys(YAML_SERVERS))}
- isEditingScope={scope}
- showConfiguredOnly={showConfiguredOnly}
- editSessionId={edits.editSessionId}
- />
-
-
+
+
+
+ deriveFieldValue(path, fallback, data)}
+ onChange={edits.handleFieldChange}
+ onDiscardField={edits.handleDiscardField}
+ onHideField={edits.handleHideField}
+ onUnhideField={edits.handleUnhideField}
+ onRenameEntry={edits.handleRenameEntry}
+ editedValues={editedValues}
+ yamlBaseKeys={new Set(Object.keys(YAML_SERVERS))}
+ isEditingScope={scope}
+ showConfiguredOnly={showConfiguredOnly}
+ editSessionId={edits.editSessionId}
+ />
+
+
+
);
}
@@ -763,6 +769,226 @@ describe('McpServersRenderer — delete, undo and rename (MCP-12, D9)', () => {
});
});
+describe('McpServersRenderer — dotted record keys (dottedKeys)', () => {
+ const dottedContract: t.ConfigContract = {
+ ...BUNDLED_CONFIG_CONTRACT,
+ features: { ...BUNDLED_CONFIG_CONTRACT.features, dottedKeys: true },
+ };
+ const HEADERS: t.ConfigRecord = { 'X-Team': 'docs', 'x.api.version': '2024-10' };
+ const USER_VARS: t.ConfigRecord = {
+ 'MY.TOKEN': { title: 'Token', description: 'Your token' },
+ };
+ const versioned: t.ConfigRecord = {
+ versioned: {
+ type: 'streamable-http',
+ url: 'https://mcp.versioned.dev/mcp',
+ headers: HEADERS,
+ customUserVars: USER_VARS,
+ },
+ };
+
+ /** Pending edit paths under a server's record field (none means it was written whole). */
+ function splitPaths(server: string, field: string): string[] {
+ return Object.keys(edits.editedValues).filter((path) =>
+ path.startsWith(`mcpServers.${server}.${field}.`),
+ );
+ }
+
+ it('creates a server with a dotted header as one headers record', async () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /com_config_create_mcp_server/ }));
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(
+ within(dialog).getByPlaceholderText('com_config_mcp_create_name_placeholder'),
+ { target: { value: 'srv-v' } },
+ );
+ const url = dialog.querySelector('#mcpDraft-url input, input#mcpDraft-url');
+ if (!url) throw new Error('no URL input');
+ fireEvent.change(url, { target: { value: 'https://mcp.example.com/v' } });
+ fireEvent.blur(url);
+ const headers = dialog.querySelector('#mcpDraft-headers');
+ if (!headers) throw new Error('no headers editor');
+ for (const [n, [key, value]] of Object.entries(HEADERS).entries()) {
+ fireEvent.click(within(headers).getByRole('button', { name: /com_ui_add_item/ }));
+ const keyInput = within(headers).getByRole('textbox', { name: `com_ui_key ${n + 1}` });
+ fireEvent.change(keyInput, { target: { value: key } });
+ fireEvent.blur(keyInput);
+ const valueInput = within(headers).getByRole('textbox', { name: `com_ui_value ${n + 1}` });
+ fireEvent.change(valueInput, { target: { value } });
+ fireEvent.blur(valueInput);
+ }
+ const create = within(dialog).getByRole('button', { name: 'com_ui_create' });
+ expect(create).toBeEnabled();
+ await act(async () => {
+ fireEvent.click(create);
+ });
+ expect(edits.editedValues['mcpServers.srv-v.headers']).toEqual(HEADERS);
+ expect(splitPaths('srv-v', 'headers')).toEqual([]);
+ });
+
+ /** Opens Create with a valid URL and adds the user variable `MY.TOKEN`, its fields still empty. */
+ async function createWithDottedVariable() {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /com_config_create_mcp_server/ }));
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(
+ within(dialog).getByPlaceholderText('com_config_mcp_create_name_placeholder'),
+ { target: { value: 'srv-u' } },
+ );
+ const url = dialog.querySelector('#mcpDraft-url input, input#mcpDraft-url');
+ if (!url) throw new Error('no URL input');
+ fireEvent.change(url, { target: { value: 'https://mcp.example.com/u' } });
+ fireEvent.blur(url);
+ fireEvent.click(within(dialog).getByRole('button', { name: /com_config_mcp_group_user_vars/ }));
+ const vars = dialog.querySelector('#mcpDraft-customUserVars');
+ if (!vars) throw new Error('no user variables editor');
+ fireEvent.click(within(vars).getByRole('button', { name: /com_ui_add_item/ }));
+ fireEvent.change(within(vars).getByRole('textbox', { name: 'com_ui_key' }), {
+ target: { value: 'MY.TOKEN' },
+ });
+ fireEvent.click(within(vars).getByRole('button', { name: 'com_ui_add' }));
+ return { dialog, vars };
+ }
+
+ it('creates a server with a dotted user variable as one customUserVars record', async () => {
+ const { dialog, vars } = await createWithDottedVariable();
+ for (const [leaf, value] of [
+ ['title', 'Token'],
+ ['description', 'Your token'],
+ ]) {
+ const input = vars.querySelector(
+ `input[id$="-${leaf}"], [id$="-${leaf}"] input, textarea[id$="-${leaf}"]`,
+ );
+ if (!input) throw new Error(`no ${leaf} input`);
+ fireEvent.change(input, { target: { value } });
+ fireEvent.blur(input);
+ }
+ const create = within(dialog).getByRole('button', { name: 'com_ui_create' });
+ expect(create).toBeEnabled();
+ await act(async () => {
+ fireEvent.click(create);
+ });
+ expect(edits.editedValues['mcpServers.srv-u.customUserVars']).toEqual(USER_VARS);
+ expect(splitPaths('srv-u', 'customUserVars')).toEqual([]);
+ });
+
+ it('duplicates a server keeping its dotted header and user variable whole', async () => {
+ render();
+ openMenu('versioned');
+ await act(async () => {
+ fireEvent.click(menuItem('com_config_mcp_action_duplicate'));
+ });
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned-copy.type': 'streamable-http',
+ 'mcpServers.versioned-copy.url': 'https://mcp.versioned.dev/mcp',
+ 'mcpServers.versioned-copy.headers': HEADERS,
+ 'mcpServers.versioned-copy.customUserVars': USER_VARS,
+ });
+ });
+
+ it('keeps the record whole on an older server too, so Save refuses the key instead of splitting it', async () => {
+ render();
+ openMenu('versioned');
+ await act(async () => {
+ fireEvent.click(menuItem('com_config_mcp_action_duplicate'));
+ });
+ expect(edits.editedValues['mcpServers.versioned-copy.headers']).toEqual(HEADERS);
+ expect(splitPaths('versioned-copy', 'headers')).toEqual([]);
+ });
+
+ it('renames a server keeping its dotted header and user variable whole', async () => {
+ render();
+ openMenu('versioned');
+ await act(async () => {
+ fireEvent.click(menuItem('com_config_mcp_action_rename'));
+ });
+ const input = screen.getByRole('textbox', { name: 'com_a11y_rename_entry:versioned' });
+ fireEvent.change(input, { target: { value: 'versioned-v2' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned': undefined,
+ 'mcpServers.versioned-v2.type': 'streamable-http',
+ 'mcpServers.versioned-v2.url': 'https://mcp.versioned.dev/mcp',
+ 'mcpServers.versioned-v2.headers': HEADERS,
+ 'mcpServers.versioned-v2.customUserVars': USER_VARS,
+ });
+ expand('versioned-v2');
+ const headers = card('versioned-v2').querySelector(
+ '#mcpServers-versioned-v2-headers',
+ );
+ if (!headers) throw new Error('no headers editor');
+ expect(within(headers).getByDisplayValue('x.api.version')).toBeInTheDocument();
+ });
+
+ describe('user variables of a saved server', () => {
+ const PLAIN: t.ConfigRecord = { title: 'Region', description: 'Your region' };
+ const mixed: t.ConfigRecord = {
+ versioned: {
+ type: 'streamable-http',
+ url: 'https://mcp.versioned.dev/mcp',
+ customUserVars: { ...USER_VARS, REGION: PLAIN },
+ },
+ };
+
+ /** The user variables editor of `versioned`, its group open. */
+ function userVariables(): HTMLElement {
+ expand('versioned');
+ openGroup('versioned', 'com_config_mcp_group_user_vars');
+ const vars = card('versioned').querySelector(
+ '#mcpServers-versioned-customUserVars',
+ );
+ if (!vars) throw new Error('no user variables editor');
+ return vars;
+ }
+
+ function retitle(vars: HTMLElement, name: string, title: string) {
+ const entry = vars.querySelector(
+ `#section-mcpServers-versioned-customUserVars-${name}`,
+ );
+ if (!entry) throw new Error(`no card for ${name}`);
+ const toggle = entry.querySelector('button[aria-expanded]');
+ if (toggle?.getAttribute('aria-expanded') !== 'true') fireEvent.click(toggle!);
+ const input = entry.querySelector('input[id$="-title"]');
+ if (!input) throw new Error(`no title input for ${name}`);
+ fireEvent.change(input, { target: { value: title } });
+ fireEvent.blur(input);
+ }
+
+ it('writes only the edited variable when a dotted one is inherited from Base', () => {
+ render();
+ expand('versioned');
+ fireEvent.click(
+ within(card('versioned')).getByRole('button', { name: 'com_config_mcp_override_fields' }),
+ );
+ retitle(userVariables(), 'REGION', 'Area');
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned.customUserVars.REGION': { ...PLAIN, title: 'Area' },
+ });
+ });
+
+ it('writes only the edited variable of a Base server whose other variable is dotted', () => {
+ render();
+ retitle(userVariables(), 'REGION', 'Area');
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned.customUserVars.REGION': { ...PLAIN, title: 'Area' },
+ });
+ });
+
+ it('keeps the variables one record while removing a dotted one is pending', () => {
+ render();
+ const vars = userVariables();
+ fireEvent.click(within(vars).getByRole('button', { name: 'com_ui_delete MY.TOKEN' }));
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned.customUserVars': { REGION: PLAIN },
+ });
+ retitle(vars, 'REGION', 'Area');
+ expect(edits.editedValues).toEqual({
+ 'mcpServers.versioned.customUserVars': { REGION: { ...PLAIN, title: 'Area' } },
+ });
+ });
+ });
+});
+
describe('McpServersRenderer — profiles (MCP-10)', () => {
it('lists every inherited server read-only until its fields are overridden', () => {
render();
diff --git a/src/components/configuration/sections/mcp/__tests__/logic.test.ts b/src/components/configuration/sections/mcp/__tests__/logic.test.ts
index 181d97da..2bb2d986 100644
--- a/src/components/configuration/sections/mcp/__tests__/logic.test.ts
+++ b/src/components/configuration/sections/mcp/__tests__/logic.test.ts
@@ -251,4 +251,13 @@ describe('leafEntries', () => {
[['d'], {}],
]);
});
+
+ it('keeps an object with a dotted key whole, so no dot-path splits the key', () => {
+ const headers = { 'X-Team': 'docs', 'x.api.version': '2024-10' };
+ expect(leafEntries({ url: 'https://x.dev', headers, apiKey: { source: 'admin' } })).toEqual([
+ [['url'], 'https://x.dev'],
+ [['headers'], headers],
+ [['apiKey', 'source'], 'admin'],
+ ]);
+ });
});
diff --git a/src/components/configuration/sections/mcp/list.ts b/src/components/configuration/sections/mcp/list.ts
index 230ac4cf..2607b5b4 100644
--- a/src/components/configuration/sections/mcp/list.ts
+++ b/src/components/configuration/sections/mcp/list.ts
@@ -1,5 +1,6 @@
import type * as t from '@/types';
import { isPlainObject, resolveEntryKey, setLeaf, deepClone } from './validation';
+import { hasDottedKey } from '@/utils';
function readLeaf(value: t.ConfigValue, segments: string[]): t.ConfigValue {
let cursor = value;
@@ -10,14 +11,19 @@ function readLeaf(value: t.ConfigValue, segments: string[]): t.ConfigValue {
return cursor;
}
-/** Every leaf of an object as `[segments, value]`; arrays and scalars are leaves. */
+/**
+ * Every leaf of an object as `[segments, value]`; arrays and scalars are
+ * leaves, and so is an object with a dotted key (`hasDottedKey`: a header
+ * named `x.api.version`), which a dot-path built from the segments would
+ * split, so it is written whole.
+ */
export function leafEntries(
value: t.ConfigRecord,
prefix: string[] = [],
): Array<[string[], t.ConfigValue]> {
return Object.entries(value).flatMap(([key, child]): Array<[string[], t.ConfigValue]> => {
const segments = [...prefix, key];
- if (isPlainObject(child) && Object.keys(child).length > 0) {
+ if (isPlainObject(child) && Object.keys(child).length > 0 && !hasDottedKey(child)) {
return leafEntries(child, segments);
}
return [[segments, child]];
diff --git a/src/components/configuration/sections/mcp/names.ts b/src/components/configuration/sections/mcp/names.ts
index f9cfadba..91332213 100644
--- a/src/components/configuration/sections/mcp/names.ts
+++ b/src/components/configuration/sections/mcp/names.ts
@@ -1,5 +1,3 @@
-import { isUnsafeConfigKey } from '@/utils';
-
/** Characters LibreChat keeps in tool names as they are (others are replaced with `_`). */
const NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
@@ -10,13 +8,14 @@ const TOOL_DELIMITER = '_mcp_';
/**
* Locale key of what is wrong with a new server name, or null: required,
- * no dots or leading `$` (the admin API drops such keys), not a reserved
+ * no dots or leading `$` (a field path can't address a dotted name, and the
+ * admin API drops or refuses `$` keys), not a reserved
* word, letters, numbers, `-` and `_` only, not LibreChat's tool delimiter,
* and not a name already in the list (in any letter case).
*/
export function serverNameProblem(name: string, existing: Iterable): string | null {
if (name === '') return 'com_config_server_name_required';
- if (isUnsafeConfigKey(name)) return 'com_config_server_name_no_dots';
+ if (name.includes('.') || name.startsWith('$')) return 'com_config_server_name_no_dots';
if (RESERVED.has(name)) return 'com_config_server_name_invalid';
if (!NAME_PATTERN.test(name)) return 'com_config_mcp_name_chars';
if (name.includes(TOOL_DELIMITER)) return 'com_config_mcp_name_delimiter';
diff --git a/src/components/configuration/sections/mcp/transport.ts b/src/components/configuration/sections/mcp/transport.ts
index c743d66b..90da6752 100644
--- a/src/components/configuration/sections/mcp/transport.ts
+++ b/src/components/configuration/sections/mcp/transport.ts
@@ -1,5 +1,6 @@
import type * as t from '@/types';
import { isConfigRecord, hasConfigValue } from '@/utils';
+import { PROCESS_MCP_FIELDS } from '@/constants';
/** Transports in the order the type choice lists them: Streamable HTTP is the default and recommended. */
export const TRANSPORTS = ['streamable-http', 'sse', 'websocket', 'stdio'] as const;
@@ -16,13 +17,7 @@ export const INSPECTOR_DERIVED: ReadonlySet = new Set([
]);
/** A stdio server's process fields: only librechat.yaml can set them (D10). */
-export const PROCESS_FIELDS: ReadonlySet = new Set([
- 'command',
- 'args',
- 'env',
- 'cwd',
- 'stderr',
-]);
+export const PROCESS_FIELDS: ReadonlySet = new Set(PROCESS_MCP_FIELDS);
/** Fields only one kind of transport reads. */
const TRANSPORT_FIELDS: Readonly>> = {
diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx
index fa1158fc..84128a55 100644
--- a/src/components/users/UsersPage.tsx
+++ b/src/components/users/UsersPage.tsx
@@ -19,12 +19,12 @@ import {
ScreenReaderAnnouncer,
SearchInput,
} from '@/components/shared';
+import { SystemCapabilities, USER_DELETE_UNAVAILABLE } from '@/constants';
import { useAnnouncement, useCapabilities, useLocalize } from '@/hooks';
-import { cn, notifySuccess, notifyError } from '@/utils';
+import { cn, errorText, notifySuccess, notifyError } from '@/utils';
import { CreateUserDialog } from './CreateUserDialog';
import { UserDetailDialog } from './UserDetailDialog';
import { ConfirmDialog } from '@/components/access';
-import { SystemCapabilities } from '@/constants';
const ROLE_FILTER_LABELS: Record = {
all: 'com_ui_all',
@@ -72,7 +72,12 @@ export function UsersPage() {
notifySuccess(localize('com_toast_user_deleted', { name: user.name }));
setDeleteTarget(null);
},
- onError: (err: Error) => notifyError(err.message),
+ onError: (err: Error) =>
+ notifyError(
+ err.message === USER_DELETE_UNAVAILABLE
+ ? localize('com_users_delete_unavailable')
+ : errorText(err, localize('com_users_delete_failed'), localize),
+ ),
});
const applyFilters = (list: TUser[], q: string, role: t.RoleFilter) =>
diff --git a/src/constants/config.ts b/src/constants/config.ts
index 040fa77e..8daf6ec7 100644
--- a/src/constants/config.ts
+++ b/src/constants/config.ts
@@ -2,15 +2,15 @@ import { BASE_ONLY_CONFIG_SECTIONS, BASE_PRINCIPAL_CONFIG_SECTIONS } from 'libre
import type * as t from '@/types';
/**
- * LibreChat's request sanitizer silently drops object keys that contain `.` or
- * start with `$` from every admin config write, so the panel blocks them up
- * front. Set to `false` once the admin config API accepts such keys.
+ * Issue codes of a record key the admin API can't store. `UNSAFE_KEY`: an
+ * older LibreChat's request sanitizer silently drops keys that contain `.`
+ * or start with `$`. `RESERVED_KEY`: a server that keeps dotted keys
+ * (`dottedKeys`) refuses `$`-prefixed keys and the prototype names.
*/
-export const BLOCK_UNSAFE_CONFIG_KEYS = true;
+export const UNSAFE_KEY_CODES: ReadonlySet = new Set(['UNSAFE_KEY', 'RESERVED_KEY']);
-/** Why a key blocked by `BLOCK_UNSAFE_CONFIG_KEYS` cannot be saved or imported. */
-export const UNSAFE_CONFIG_KEY_MESSAGE =
- "keys containing '.' or starting with '$' can't be saved through the admin API yet; set this in librechat.yaml";
+/** `deleteUserFn`'s error when the LibreChat server has no admin user-delete route (it ships disabled). */
+export const USER_DELETE_UNAVAILABLE = 'user_delete_unavailable';
/** The admin API's 403 code while the signed-in admin hasn't accepted LibreChat's terms of service. */
export const TERMS_NOT_ACCEPTED_CODE = 'terms_not_accepted';
@@ -67,10 +67,32 @@ export const APP_SERVICE_KEY_ALIASES: Readonly> = {
mcpConfig: 'mcpServers',
};
+/**
+ * Settings LibreChat reads once at startup, from librechat.yaml alone
+ * (`getAppConfig({ baseOnly: true })` while the server boots), so an admin
+ * override never takes effect: the agents' background, event and approval
+ * hook runtime, and MCP catalog recovery's state limits and fence retry
+ * worker. `catalogRecovery.authorizationFenceTimeoutMs` is not one: requests
+ * read it from the merged config too.
+ */
+export const STARTUP_ONLY_FIELDS: ReadonlySet = new Set([
+ 'endpoints.agents.eventDriven',
+ 'endpoints.agents.subagentActivity',
+ 'endpoints.agents.toolApproval.hooks',
+ 'endpoints.agents.backgroundTasks.shutdownInterruptGraceMs',
+ 'endpoints.agents.backgroundTasks.completionResultBatchSize',
+ 'endpoints.agents.backgroundTasks.completionReceiptBatching',
+ 'mcpSettings.catalogRecovery.maxStateEntries',
+ 'mcpSettings.catalogRecovery.maxDetachedDiscoveries',
+ 'mcpSettings.catalogRecovery.authorizationFenceRetryIntervalMs',
+ 'mcpSettings.catalogRecovery.authorizationFenceRetryBatchSize',
+]);
+
/**
* Fields LibreChat only processes from librechat.yaml (overrides are merged
- * after `azureConfigSetup`/`vertexConfigSetup` run), so edits would be silent
- * no-ops. Rendered read-only until the backend re-processes overrides.
+ * after `azureConfigSetup`/`vertexConfigSetup` run, or read only at startup),
+ * so edits would be silent no-ops. Rendered read-only until the backend
+ * re-processes overrides.
*/
export const YAML_MANAGED_FIELDS: ReadonlySet = new Set([
'endpoints.azureOpenAI.groups',
@@ -80,6 +102,7 @@ export const YAML_MANAGED_FIELDS: ReadonlySet = new Set([
/** Social sign-in and OpenID discovery are set up once at startup from librechat.yaml. */
'registration.oauthStateTtlMs',
'registration.openidDiscovery',
+ ...STARTUP_ONLY_FIELDS,
]);
/** Config keys for values that must never be displayed in plain text. */
@@ -107,6 +130,12 @@ export const SKIP_REASON_KEYS: Readonly> = {
inheritedMerged: 'com_scope_skip_inherited_merged',
};
+/** Why a lower-layer key comes back after a write: LibreChat merges it in, so it is reported but not lost. */
+export const MERGED_BACK_REASONS: ReadonlySet = new Set([
+ 'yamlMerged',
+ 'inheritedMerged',
+]);
+
/** The top-level config section of a dot-path (`balance.startBalance` → `balance`). */
export function getConfigSection(path: string): string {
const dot = path.indexOf('.');
@@ -118,10 +147,19 @@ export function getReadOnlyReason(path: string): t.ReadOnlyReason | undefined {
return READ_ONLY_CONFIG_SECTIONS.get(getConfigSection(path));
}
-/** Whether `path` is, or is inside, a field managed only in librechat.yaml. */
-export function isYamlManagedPath(path: string): boolean {
- for (const managed of YAML_MANAGED_FIELDS) {
- if (path === managed || path.startsWith(`${managed}.`)) return true;
+function isAtOrUnderAny(path: string, fields: ReadonlySet): boolean {
+ for (const field of fields) {
+ if (path === field || path.startsWith(`${field}.`)) return true;
}
return false;
}
+
+/** Whether `path` is, or is inside, a field managed only in librechat.yaml. */
+export function isYamlManagedPath(path: string): boolean {
+ return isAtOrUnderAny(path, YAML_MANAGED_FIELDS);
+}
+
+/** Whether `path` is, or is inside, a setting LibreChat reads only at startup (`STARTUP_ONLY_FIELDS`). */
+export function isStartupOnlyPath(path: string): boolean {
+ return isAtOrUnderAny(path, STARTUP_ONLY_FIELDS);
+}
diff --git a/src/constants/contract.ts b/src/constants/contract.ts
new file mode 100644
index 00000000..c0a4214d
--- /dev/null
+++ b/src/constants/contract.ts
@@ -0,0 +1,127 @@
+import {
+ configSchema,
+ PERMISSION_SUB_KEYS,
+ INTERFACE_PERMISSION_FIELDS,
+ BASE_PRINCIPAL_CONFIG_SECTIONS,
+ RUNTIME_CONFIG_INTERFACE_FIELDS,
+} from 'librechat-data-provider';
+import type * as t from '@/types';
+import { APP_SERVICE_KEY_ALIASES, READ_ONLY_CONFIG_SECTIONS } from './config';
+import { KEYED_COLLECTIONS } from './collections';
+
+/** Every flag of the admin config contract the panel reads. */
+export const CONFIG_FEATURE_FLAGS: readonly t.ConfigFeatureFlag[] = [
+ 'dottedKeys',
+ 'sectionAllowlist',
+ 'readFilteredWriteResponses',
+ 'preAuthBaseOverrides',
+ 'webSearchStoredKeys',
+ 'expectedRevision',
+ 'prunesEmptyParents',
+ 'awaitedInvalidation',
+ 'changesBatch',
+ 'yamlMcpHide',
+ 'deterministicPrecedence',
+ 'priorityRoute',
+ 'principalCheck',
+ 'roleRenameMovesProfile',
+ 'recordSecrets',
+ 'azureGroupSecrets',
+ 'yamlSecretPreview',
+ 'envAllowlist',
+ 'secretRebinding',
+];
+
+/** A server without the meta route (or one that leaves a flag out) has none of these behaviours. */
+export const BUNDLED_CONFIG_FEATURES: t.ConfigFeatures = Object.fromEntries(
+ CONFIG_FEATURE_FLAGS.map((flag) => [flag, false]),
+) as Record;
+
+/** The limits LibreChat enforced before it reported them. */
+export const BUNDLED_CONFIG_LIMITS: t.ConfigLimits = {
+ maxPatchEntries: 100,
+ maxChangeOps: 0,
+ overrideCacheTtlMs: 60_000,
+};
+
+/** A stdio MCP server's process fields: only librechat.yaml sets them (D10). */
+export const PROCESS_MCP_FIELDS: readonly string[] = ['command', 'args', 'env', 'cwd', 'stderr'];
+
+/** Secrets LibreChat encrypted before it reported its registry (`${VAR}` references stay readable). */
+const BUNDLED_SECRET_PATHS: ReadonlyArray<[path: string, allowEnvPlaceholder: boolean]> = [
+ ['langfuse.secretKey', false],
+ ['ocr.apiKey', true],
+ ['speech.tts.openai.apiKey', true],
+ ['speech.tts.azureOpenAI.apiKey', true],
+ ['speech.tts.elevenlabs.apiKey', true],
+ ['speech.tts.localai.apiKey', true],
+ ['speech.stt.openai.apiKey', true],
+ ['speech.stt.azureOpenAI.apiKey', true],
+ ['webSearch.serperApiKey', true],
+ ['webSearch.searxngApiKey', true],
+ ['webSearch.firecrawlApiKey', true],
+ ['webSearch.tavilyApiKey', true],
+ ['webSearch.jinaApiKey', true],
+ ['webSearch.cohereApiKey', true],
+ ['endpoints.assistants.apiKey', true],
+ ['endpoints.azureAssistants.apiKey', true],
+];
+
+const keyedCollections = [...KEYED_COLLECTIONS.values()];
+
+/**
+ * The write rules of a LibreChat server that does not report its own, as the
+ * panel knows them: the bundled config schema and data-provider constants,
+ * and the panel's mirrors of LibreChat's merge rules.
+ */
+export const BUNDLED_CONFIG_RULES: t.ConfigRules = {
+ acceptedSections: Object.keys(configSchema.shape),
+ baseOnlySections: [...READ_ONLY_CONFIG_SECTIONS]
+ .filter(([, reason]) => reason === 'baseOnly')
+ .map(([section]) => section),
+ basePrincipalSections: [...BASE_PRINCIPAL_CONFIG_SECTIONS],
+ baseOnlyPaths: [],
+ arrayMergeKeys: Object.fromEntries(
+ keyedCollections
+ .filter((collection) => collection.merge === 'byKey')
+ .map((collection) => [collection.path, collection.key]),
+ ),
+ atomicMergePaths: ['interface.theme'],
+ overrideKeyMap: Object.fromEntries(
+ Object.entries(APP_SERVICE_KEY_ALIASES).map(([alias, section]) => [section, alias]),
+ ),
+ secretFields: BUNDLED_SECRET_PATHS.map(([path, allowEnvPlaceholder]) => ({
+ path,
+ previewPath: `${path}Preview`,
+ allowEnvPlaceholder,
+ })),
+ arraySecretFields: keyedCollections.flatMap((collection) =>
+ collection.secret
+ ? [
+ {
+ arrayPath: collection.path,
+ secretKey: collection.secret.key,
+ previewKey: collection.secret.preview,
+ identityKey: collection.key,
+ },
+ ]
+ : [],
+ ),
+ recordSecretFields: [],
+ refusedMaps: ['langfuse.headers'],
+ processMcpFields: [...PROCESS_MCP_FIELDS],
+ interface: {
+ permissionFields: [...INTERFACE_PERMISSION_FIELDS],
+ runtimeFields: [...RUNTIME_CONFIG_INTERFACE_FIELDS],
+ permissionSubKeys: [...PERMISSION_SUB_KEYS],
+ },
+ titleResolution: 'block',
+};
+
+/** The contract of a LibreChat server without `GET /api/admin/config/meta`: every behaviour as before it. */
+export const BUNDLED_CONFIG_CONTRACT: t.ConfigContract = {
+ source: 'bundled',
+ features: BUNDLED_CONFIG_FEATURES,
+ limits: BUNDLED_CONFIG_LIMITS,
+ rules: BUNDLED_CONFIG_RULES,
+};
diff --git a/src/constants/index.ts b/src/constants/index.ts
index a114d861..1ed3112d 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -4,6 +4,7 @@ export * from './role';
export * from './scope';
export * from './config';
export * from './collections';
+export * from './contract';
export * from './providers';
export * from './endpoints';
export * from './features';
diff --git a/src/contexts/ContractContext.tsx b/src/contexts/ContractContext.tsx
new file mode 100644
index 00000000..f4694fab
--- /dev/null
+++ b/src/contexts/ContractContext.tsx
@@ -0,0 +1,22 @@
+import { createContext } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import type * as t from '@/types';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
+import { configContractOptions } from '@/server';
+
+/**
+ * The connected LibreChat server's admin config contract. Outside the
+ * provider, and until the server's has loaded, it is the bundled contract:
+ * every behaviour as it was before servers reported one.
+ */
+export const ConfigContractContext = createContext(BUNDLED_CONFIG_CONTRACT);
+
+/** Reads the server's contract once for the whole app (see `getAdminConfigMetaFn`). */
+export function ConfigContractProvider({ children }: t.ConfigContractProviderProps) {
+ const { data } = useQuery(configContractOptions);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/config/contract.ts b/src/hooks/config/contract.ts
new file mode 100644
index 00000000..aa7bee95
--- /dev/null
+++ b/src/hooks/config/contract.ts
@@ -0,0 +1,31 @@
+import { useContext, useCallback } from 'react';
+import type * as t from '@/types';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { useLocalize } from '../useLocalize';
+import { isUnsafeConfigKey } from '@/utils';
+
+/** The connected server's admin config contract (the bundled one until it loads, or without one). */
+export function useConfigContract(): t.ConfigContract {
+ return useContext(ConfigContractContext);
+}
+
+/** Which admin config behaviours the connected server has (all off for an older server). */
+export function useConfigFeatures(): t.ConfigFeatures {
+ return useContext(ConfigContractContext).features;
+}
+
+/**
+ * Why a record key can't be saved, or null: a key the admin API would drop
+ * (`$`-prefixed, a prototype name, and before `dottedKeys` any key with a dot).
+ */
+export function useConfigKeyProblem(): (key: string) => string | null {
+ const localize = useLocalize();
+ const { dottedKeys } = useConfigFeatures();
+ return useCallback(
+ (key: string) => {
+ if (!isUnsafeConfigKey(key, dottedKeys)) return null;
+ return localize(dottedKeys ? 'com_config_reserved_key_error' : 'com_config_unsafe_key_error');
+ },
+ [dottedKeys, localize],
+ );
+}
diff --git a/src/hooks/config/import.test.tsx b/src/hooks/config/import.test.tsx
index e74a69ef..e8772fc5 100644
--- a/src/hooks/config/import.test.tsx
+++ b/src/hooks/config/import.test.tsx
@@ -1,13 +1,16 @@
+import '@/locales/i18n';
import { describe, it, expect, vi } from 'vitest';
+import { createToast } from '@clickhouse/click-ui';
+import { act, renderHook } from '@testing-library/react';
import { configSchema, PrincipalType } from 'librechat-data-provider';
-import { renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import type * as t from '@/types';
import { extractSchemaTree, getSchemaPathSet } from '@/server/config';
-import { useImportPreview } from './import';
-import { flattenObject } from '@/utils';
-import '@/locales/i18n';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { useImportPreview, useConfigImport } from './import';
+import { flattenObject, setValueAtPath } from '@/utils';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
vi.mock('@/server', () => ({
baseConfigOptions: { queryKey: ['baseConfig'], queryFn: vi.fn(), staleTime: Infinity },
@@ -16,6 +19,11 @@ vi.mock('@/server', () => ({
createGroupFn: vi.fn(),
}));
+vi.mock('@clickhouse/click-ui', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, createToast: vi.fn() };
+});
+
const tree = extractSchemaTree(configSchema as t.ZodSchemaLike);
const schemaPathSet = getSchemaPathSet();
const moonshot = {
@@ -84,15 +92,28 @@ const engineeringData: t.ResolvedConfigData = {
tombstones: [],
};
-function renderPreview(appConfig: t.ConfigRecord, target: t.ImportTarget = { type: 'base' }) {
+function renderPreview(
+ appConfig: t.ConfigRecord,
+ target: t.ImportTarget = { type: 'base' },
+ {
+ features = {},
+ baseData = base,
+ }: { features?: Partial; baseData?: t.BaseConfigData } = {},
+) {
const client = new QueryClient();
- client.setQueryData(['baseConfig'], base);
+ client.setQueryData(['baseConfig'], baseData);
client.setQueryData(
['resolvedConfig', engineering.principalType, engineering.principalId],
engineeringData,
);
+ const contract: t.ConfigContract = {
+ ...BUNDLED_CONFIG_CONTRACT,
+ features: { ...BUNDLED_CONFIG_CONTRACT.features, ...features },
+ };
const wrapper = ({ children }: { children: ReactNode }) => (
- {children}
+
+ {children}
+
);
return renderHook(
() => useImportPreview({ schemaTree: tree, schemaPathSet, appConfig, target }),
@@ -101,6 +122,109 @@ function renderPreview(appConfig: t.ConfigRecord, target: t.ImportTarget = { typ
}
describe('useImportPreview', () => {
+ it('on a server that keeps dotted keys, merges an imported dotted record into the stored one', () => {
+ const profiles = { 'us.anthropic.a': 'arn:a' };
+ const bedrock = { inferenceProfiles: profiles };
+ const baseData: t.BaseConfigData = {
+ ...base,
+ config: setValueAtPath(base.config, 'endpoints.bedrock', bedrock),
+ dbOverrides: setValueAtPath(dbOverrides, 'endpoints.bedrock', bedrock),
+ };
+ const { result } = renderPreview(
+ { endpoints: { bedrock: { inferenceProfiles: { 'us.anthropic.b': 'arn:b' } } } },
+ { type: 'base' },
+ { features: { dottedKeys: true }, baseData },
+ );
+ const rows = result.current.groups.flatMap((group) => group.rows.flatMap((row) => row.rows));
+ expect(rows.map((row) => row.item.kind)).toEqual(['added']);
+ expect(result.current.entriesFor(new Set(['endpoints.bedrock.inferenceProfiles']))).toEqual([
+ {
+ fieldPath: 'endpoints.bedrock.inferenceProfiles',
+ value: { 'us.anthropic.a': 'arn:a', 'us.anthropic.b': 'arn:b' },
+ },
+ ]);
+ });
+
+ it('on a server that keeps dotted keys, never lists librechat.yaml record keys as removed', () => {
+ const bedrock = { inferenceProfiles: { 'us.anthropic.a': 'arn:a' } };
+ const baseData: t.BaseConfigData = {
+ ...base,
+ config: setValueAtPath(base.config, 'endpoints.bedrock', bedrock),
+ yamlConfig: setValueAtPath(yamlConfig, 'endpoints.bedrock', bedrock),
+ };
+ const { result } = renderPreview(
+ { endpoints: { bedrock: { inferenceProfiles: { 'us.anthropic.b': 'arn:b' } } } },
+ { type: 'base' },
+ { features: { dottedKeys: true }, baseData },
+ );
+ const rows = result.current.groups.flatMap((group) => group.rows.flatMap((row) => row.rows));
+ expect(rows.map((row) => [row.item.kind, row.item.path])).toEqual([
+ ['added', 'endpoints.bedrock.inferenceProfiles.us.anthropic.b'],
+ ]);
+ expect(result.current.entriesFor(new Set(['endpoints.bedrock.inferenceProfiles']))).toEqual([
+ { fieldPath: 'endpoints.bedrock.inferenceProfiles', value: { 'us.anthropic.b': 'arn:b' } },
+ ]);
+ });
+
+ it('on a server that keeps dotted keys, imports MCP servers field by field and leaves a dotted name out', () => {
+ const cloudflare = { type: 'streamable-http', url: 'https://mcp.cloudflare.com/mcp' };
+ const everything = { type: 'streamable-http', url: 'https://everything.example.com/mcp' };
+ const stored = { timeout: 45000, initTimeout: 20000 };
+ const yaml = setValueAtPath(yamlConfig, 'mcpServers', { cloudflare, everything });
+ const baseData: t.BaseConfigData = {
+ ...base,
+ config: setValueAtPath(base.config, 'mcpServers', {
+ cloudflare: { ...cloudflare, ...stored },
+ everything,
+ }),
+ yamlConfig: yaml,
+ yamlPaths: Object.keys(flattenObject(yaml)),
+ dbOverrides: setValueAtPath(dbOverrides, 'mcpServers', { cloudflare: stored }),
+ };
+ const { result } = renderPreview(
+ {
+ mcpServers: {
+ 'my.server': { type: 'streamable-http', url: 'https://example.com/mcp' },
+ cloudflare: { ...cloudflare, serverInstructions: 'Use carefully' },
+ everything: { serverInstructions: 'Sparse' },
+ },
+ },
+ { type: 'base' },
+ { features: { dottedKeys: true }, baseData },
+ );
+ const paths = new Set(result.current.plan?.entries.map((entry) => entry.fieldPath));
+ expect(paths.has('mcpServers')).toBe(false);
+ const rows = result.current.groups.flatMap((group) => group.rows.flatMap((row) => row.rows));
+ expect(rows.map((row) => [row.item.kind, row.item.path])).toEqual([
+ ['added', 'mcpServers.cloudflare.serverInstructions'],
+ ['added', 'mcpServers.everything.serverInstructions'],
+ ]);
+ expect(result.current.skipped).toEqual([
+ expect.objectContaining({
+ path: 'mcpServers',
+ label: expect.stringContaining('"my.server"'),
+ reason: expect.stringContaining('name with a dot'),
+ }),
+ ]);
+ expect(result.current.entriesFor(paths)).toEqual(
+ expect.arrayContaining([
+ { fieldPath: 'mcpServers.cloudflare.serverInstructions', value: 'Use carefully' },
+ { fieldPath: 'mcpServers.everything.serverInstructions', value: 'Sparse' },
+ ]),
+ );
+ expect(result.current.issuesFor(paths)).toEqual([]);
+ });
+
+ it('on an older server, leaves out a record holding a dotted key', () => {
+ const { result } = renderPreview({
+ endpoints: { bedrock: { inferenceProfiles: { 'us.anthropic.b': 'arn:b' } } },
+ });
+ expect(result.current.groups).toEqual([]);
+ expect(result.current.skipped.map((row) => row.path)).toEqual([
+ 'endpoints.bedrock.inferenceProfiles',
+ ]);
+ });
+
it('lists only the keys a partial custom endpoint sets, merged by name (D4)', () => {
const { result } = renderPreview({
endpoints: { custom: [{ name: 'Moonshot', modelDisplayLabel: 'Kimi Import' }] },
@@ -180,3 +304,54 @@ describe('useImportPreview', () => {
expect(onBase.result.current.issuesFor(all)).toEqual(['Server URL is required']);
});
});
+
+describe('useConfigImport', () => {
+ function renderImport(skipped: t.SkippedChange[]) {
+ const client = new QueryClient();
+ const writeChanges = vi.fn().mockResolvedValue(skipped);
+ const describeSkipped = (items: t.SkippedChange[]) =>
+ items.map((item) => item.fieldPath).join(', ');
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(() => useConfigImport({ writeChanges, describeSkipped }), {
+ wrapper,
+ });
+ return { result, writeChanges };
+ }
+ const entries: t.SaveEntry[] = [
+ { fieldPath: 'mcpServers.github.headers', value: { 'x-team.id': 'eng' } },
+ { fieldPath: 'balance.startBalance', value: 5 },
+ ];
+
+ it('reports keys LibreChat merges back as the merge asked for, not as left out', async () => {
+ const toast = vi.mocked(createToast);
+ toast.mockClear();
+ const { result, writeChanges } = renderImport([
+ { fieldPath: 'mcpServers.github.headers.Authorization', reason: 'yamlMerged' },
+ ]);
+ await act(() => result.current.handleImport({ target: { type: 'base' }, entries }));
+ expect(writeChanges).toHaveBeenCalledWith({ type: 'base' }, entries, []);
+ expect(toast).toHaveBeenCalledWith({
+ type: 'success',
+ title: 'Imported 2 values into the base configuration',
+ });
+ });
+
+ it('still reports a value that was not stored', async () => {
+ const toast = vi.mocked(createToast);
+ toast.mockClear();
+ const { result } = renderImport([
+ { fieldPath: 'mcpServers.github.headers.Authorization', reason: 'yamlMerged' },
+ { fieldPath: 'balance.startBalance', reason: 'notStored' },
+ ]);
+ await act(() => result.current.handleImport({ target: { type: 'base' }, entries }));
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'warning',
+ title: 'Imported 1 value into the base configuration',
+ description: 'balance.startBalance',
+ }),
+ );
+ });
+});
diff --git a/src/hooks/config/import.ts b/src/hooks/config/import.ts
index 38e807f9..86db70af 100644
--- a/src/hooks/config/import.ts
+++ b/src/hooks/config/import.ts
@@ -3,24 +3,12 @@ import { PrincipalType } from 'librechat-data-provider';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type * as t from '@/types';
import {
- locate,
- fieldLabel,
- describePath,
- scopeTypeLabel,
- buildReviewModel,
- principalTypeLabel,
-} from '@/components/configuration/review/group';
-import {
- lowerValue,
- applySaves,
- identityKeyFor,
- summarizeChanges,
-} from '@/components/configuration/review/diff';
-import {
+ hasDottedKey,
notifySuccess,
notifyWarning,
flattenObject,
isConfigRecord,
+ asConfigRecord,
getValueAtPath,
revertTargetAt,
createBaseLayers,
@@ -31,12 +19,28 @@ import {
getKeyedCollection,
stripSecretPreviewValues,
} from '@/utils';
+import {
+ locate,
+ fieldLabel,
+ describePath,
+ scopeTypeLabel,
+ buildReviewModel,
+ principalTypeLabel,
+} from '@/components/configuration/review/group';
+import {
+ lowerValue,
+ applySaves,
+ identityKeyFor,
+ summarizeChanges,
+} from '@/components/configuration/review/diff';
import { createRoleFn, createScopeFn, createGroupFn, baseConfigOptions } from '@/server';
import { isConfigValueEqual, normalizeForSave } from '@/components/configuration/edits';
import { planImport, IMPORT_SKIP_KEYS } from '@/components/configuration/import';
import { findSchemaField } from '@/components/configuration/controls';
import { describeIssues } from '@/components/configuration/issues';
import { resolvedConfigOptions, runtimeDefaultAt } from './layers';
+import { MERGED_BACK_REASONS } from '@/constants';
+import { useConfigFeatures } from './contract';
import { useLocalize } from '../useLocalize';
/** Lists LibreChat merges with librechat.yaml by name; an import upserts their entries by name. */
@@ -164,6 +168,29 @@ function upsertByName(path: string, own: t.ConfigValue, incoming: t.ConfigValue)
];
}
+/**
+ * A record of plain values imported whole (it holds a dotted key, which no
+ * path can address) merges into `record` key by key, as its keys would one
+ * by one (D4): into the target's own record for the write, so keys the YAML
+ * leaves out stay, and into what the target has now for the preview, since
+ * LibreChat merges the stored record over the layer beneath it.
+ */
+function mergeIntoRecord(record: t.ConfigValue, incoming: t.ConfigRecord): t.ConfigRecord {
+ return { ...asConfigRecord(record), ...incoming };
+}
+
+/** Whether a planned entry is a record imported whole (see `mergeIntoRecord`). */
+function isWholeRecord(value: t.ConfigValue): value is t.ConfigRecord {
+ return isConfigRecord(value) && hasDottedKey(value);
+}
+
+/** An entry as the preview compares it with `baseline`: a whole record reads merged into it. */
+function previewOf(entry: t.SaveEntry, baseline: t.ConfigRecord): t.SaveEntry {
+ if (!isWholeRecord(entry.value)) return entry;
+ const value = mergeIntoRecord(getValueAtPath(baseline, entry.fieldPath), entry.value);
+ return { fieldPath: entry.fieldPath, value };
+}
+
/** Only the entries the import lists, so the preview never reads the others as removed. */
function onlyImported(
path: string,
@@ -201,6 +228,7 @@ export function useImportPreview({
target,
}: t.ImportPreviewOptions): t.ImportPreviewState {
const localize = useLocalize();
+ const features = useConfigFeatures();
const { data: base, isLoading: baseLoading } = useQuery(baseConfigOptions);
const existingScope = target?.type === 'scope' && target.exists ? target.scope : undefined;
const { data: scopeData, isLoading: scopeLoading } = useQuery(
@@ -211,8 +239,11 @@ export function useImportPreview({
const isScopeTarget = target != null && target.type !== 'base';
const plan = useMemo(
- () => (appConfig ? planImport(appConfig, schemaTree, schemaPathSet, isScopeTarget) : undefined),
- [appConfig, schemaTree, schemaPathSet, isScopeTarget],
+ () =>
+ appConfig
+ ? planImport(appConfig, schemaTree, schemaPathSet, isScopeTarget, features)
+ : undefined,
+ [appConfig, schemaTree, schemaPathSet, isScopeTarget, features],
);
/** librechat.yaml and the base overrides as layers, so a value is labelled by the layer that sets it. */
@@ -238,6 +269,11 @@ export function useImportPreview({
const isScope = target.type !== 'base';
const written = plan.entries.flatMap((entry): t.SaveEntry[] => {
const path = entry.fieldPath;
+ if (isWholeRecord(entry.value)) {
+ return [
+ { fieldPath: path, value: mergeIntoRecord(getValueAtPath(own, path), entry.value) },
+ ];
+ }
if (!MERGED_COLLECTIONS.has(path)) return [entry];
const effective = isScope
? mergeConfigLayers(getValueAtPath(base.config, path), getValueAtPath(own, path), path)
@@ -295,7 +331,7 @@ export function useImportPreview({
: baseline;
const save = isMerged
? { fieldPath: path, value: withoutPreviews(mergedEntries(path, existing, entry.value)) }
- : entry;
+ : previewOf(entry, baseline);
const items = !written.has(path)
? []
: summarizeChanges({
@@ -374,14 +410,24 @@ export function useImportPreview({
[entries, own],
);
- /** The target's stored overrides and the layer beneath, as the validated write reads them. */
+ /** The target's stored overrides and tombstones and the layer beneath, as the validated write reads them. */
const snapshot = useMemo(() => {
if (!target || !base) return null;
if (target.type === 'base') {
- return { stored: base.dbOverrides ?? {}, lower: base.yamlConfig ?? {}, lowerKind: 'yaml' };
+ return {
+ stored: base.dbOverrides ?? {},
+ tombstones: base.dbTombstones,
+ lower: base.yamlConfig ?? {},
+ lowerKind: 'yaml',
+ };
}
- return { stored: own, lower: base.config ?? {}, lowerKind: 'inherited' };
- }, [target, base, own]);
+ return {
+ stored: own,
+ tombstones: (existingScope && scopeData?.tombstones) || [],
+ lower: base.config ?? {},
+ lowerKind: 'inherited',
+ };
+ }, [target, base, own, existingScope, scopeData]);
/** The same pre-flight the write runs, so the preview refuses what Import would (an endpoint with only a name). */
const issuesFor = useCallback(
@@ -391,10 +437,10 @@ export function useImportPreview({
({ fieldPath, value }): t.ConfigChange => ({ op: 'set', path: fieldPath, value }),
);
if (ops.length === 0) return [];
- const { issues } = planConfigChanges(ops, snapshot);
+ const { issues } = planConfigChanges(ops, snapshot, features);
return describeIssues(issues, [], localize).summaries;
},
- [snapshot, entriesFor, localize],
+ [snapshot, entriesFor, features, localize],
);
return {
@@ -450,7 +496,10 @@ export function useConfigImport({
queryClient.invalidateQueries({ queryKey: ['groups'] }),
]);
}
- const skipped = await writeChanges(writeTarget, entries, []);
+ /** Keys LibreChat merges back from the layer beneath are the merge an import asks for (D4), not a loss. */
+ const skipped = (await writeChanges(writeTarget, entries, [])).filter(
+ (item) => !MERGED_BACK_REASONS.has(item.reason),
+ );
const written = Math.max(0, entries.length - skipped.length);
const message =
writeTarget.type === 'scope'
diff --git a/src/hooks/config/index.ts b/src/hooks/config/index.ts
index b5fddc6b..eb8d66b6 100644
--- a/src/hooks/config/index.ts
+++ b/src/hooks/config/index.ts
@@ -1,4 +1,5 @@
export * from './baseline';
+export * from './contract';
export * from './counts';
export * from './draft';
export * from './edits';
diff --git a/src/hooks/config/save.ts b/src/hooks/config/save.ts
index ee087773..f47a476f 100644
--- a/src/hooks/config/save.ts
+++ b/src/hooks/config/save.ts
@@ -16,15 +16,16 @@ import {
ADMIN_API_KEY_FIELD,
validateMcpCrossField,
} from '@/components/configuration/sections/mcp/validation';
+import { TERMS_NOT_ACCEPTED_ISSUE, TERMS_NOT_ACCEPTED_CODE, UNSAFE_KEY_CODES } from '@/constants';
import { buildConfigChanges, normalizeEditValue } from '@/components/configuration/edits';
import { specListIssues } from '@/components/configuration/sections/specs/validation';
import { isResetSent, summarizeChanges } from '@/components/configuration/summary';
import { describeSkippedChanges } from '@/components/configuration/review/group';
-import { TERMS_NOT_ACCEPTED_ISSUE, TERMS_NOT_ACCEPTED_CODE } from '@/constants';
import { applyConfigChangesFn, resetBaseConfigFn } from '@/server';
import { describeIssues } from '@/components/configuration/issues';
import { hasPathOverlap } from '@/components/configuration/counts';
import { labelForPath } from '@/components/configuration/meta';
+import { useConfigFeatures } from './contract';
import { useLocalize } from '../useLocalize';
function toTargetRef(target: t.WriteTarget): t.WriteTargetRef {
@@ -80,6 +81,7 @@ export function useConfigSave({
}: t.ConfigSaveOptions): t.ConfigSaveState {
const localize = useLocalize();
const queryClient = useQueryClient();
+ const features = useConfigFeatures();
const {
configValues,
dbOverrides,
@@ -242,19 +244,28 @@ export function useConfigSave({
[touchedPaths, editedValues, schemaPathSet, changeContext],
);
- /** The target's stored overrides and the layer beneath, as loaded (the server re-reads them). */
+ /** The target's stored overrides and tombstones and the layer beneath, as loaded (the server re-reads them). */
const snapshot = useMemo(() => {
if (isEditingScope) {
return {
stored: valueLayers?.scope?.overrides ?? {},
+ tombstones: [...(valueLayers?.scope?.tombstones ?? [])],
lower: configValues ?? {},
lowerKind: 'inherited',
};
}
- return { stored: dbOverrides ?? {}, lower: yamlConfig ?? {}, lowerKind: 'yaml' };
+ return {
+ stored: dbOverrides ?? {},
+ tombstones: [...(valueLayers?.baseTombstones ?? [])],
+ lower: yamlConfig ?? {},
+ lowerKind: 'yaml',
+ };
}, [isEditingScope, valueLayers, configValues, dbOverrides, yamlConfig]);
- const plan = useMemo(() => planConfigChanges(changeSet.ops, snapshot), [changeSet, snapshot]);
+ const plan = useMemo(
+ () => planConfigChanges(changeSet.ops, snapshot, features),
+ [changeSet, snapshot, features],
+ );
const clientIssues = useMemo(
() => [...changeSet.issues, ...plan.issues, ...specListIssues(changeSet)],
@@ -302,7 +313,7 @@ export function useConfigSave({
const unsafeKeys = useMemo(
() =>
plan.issues
- .filter((issue) => issue.code === 'UNSAFE_KEY')
+ .filter((issue) => issue.code != null && UNSAFE_KEY_CODES.has(issue.code))
.map((issue) => ({ path: issue.path, key: String(issue.params?.key ?? '') })),
[plan.issues],
);
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 321e1948..20b0a33b 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -322,6 +322,7 @@
"com_config_mcp_defined_in_base_yaml": "Defined in Base (librechat.yaml). Override its fields for this profile instead.",
"com_config_mcp_override_note": "{{scope}} overrides this Base server. Only the fields you change here are saved for {{scope}}.",
"com_config_mcp_dotted_name": "This server's name contains a dot, which the admin API can't store. Rename it in librechat.yaml to edit it here.",
+ "com_config_mcp_dotted_name_paths": "This server's name contains a dot, so its settings can't be saved one by one here. Rename it in librechat.yaml to edit it here.",
"com_config_mcp_draft_label": "New server",
"com_config_mcp_enum_auth_basic": "Basic auth",
"com_config_mcp_enum_auth_bearer": "Bearer token",
@@ -523,6 +524,9 @@
"com_config_review_import_show_unchanged_other": "Show {{count}} settings that are already set",
"com_config_review_import_skip_process": "process-backed (stdio) MCP servers can only be defined in librechat.yaml",
"com_config_review_import_skip_unsafe": "keys containing '.' or starting with '$' can't be saved through the admin API yet; set this in librechat.yaml",
+ "com_config_review_import_skip_reserved": "keys starting with '$', and the names __proto__, constructor and prototype, can't be saved",
+ "com_config_review_import_skip_dotted": "a key with a dot can name entries (headers, inference profiles, MIME types), not settings",
+ "com_config_review_import_skip_dotted_name": "a name with a dot can't be imported here: its settings are saved one by one, and a setting path can't name it. Rename it without the dot",
"com_config_review_import_skip_hidden_secret": "LibreChat hides the librechat.yaml key here, so the import cannot tell whether this one differs; replace it in the endpoint card",
"com_config_review_import_skipped_title_one": "{{count}} key won't be imported",
"com_config_review_import_skipped_title_other": "{{count}} keys won't be imported",
@@ -684,15 +688,26 @@
"com_config_issue_too_small_length": "must be at least {{minimum}} characters",
"com_config_issue_union_dropped_key": "isn't valid for this kind of value",
"com_config_issue_unsafe_key": "has a key LibreChat can't store yet (\"{{key}}\" contains '.' or starts with '$'); set it in librechat.yaml",
+ "com_config_issue_reserved_key": "has a key LibreChat can't store (\"{{key}}\" starts with '$' or is a reserved name); rename it",
+ "com_config_issue_dotted_key": "has a key with a dot where LibreChat expects a setting's name",
+ "com_config_issue_unknown_section": "isn't a section LibreChat knows",
+ "com_config_issue_invalid_value": "is not a valid value",
+ "com_config_issue_wrong_type": "has the wrong type of value",
+ "com_config_issue_invalid_option": "is not one of the allowed values",
+ "com_config_issue_too_small_value": "is too small",
+ "com_config_issue_too_big_value": "is too large",
"com_config_issue_yaml_entry_immutable": "\"{{name}}\" is defined in librechat.yaml, so it can't be {{action}} here",
"com_config_issue_yaml_managed": "is managed in librechat.yaml; LibreChat ignores admin overrides for it until it re-processes them",
"com_config_unsafe_key_error": "Keys containing '.' or starting with '$' can't be saved through the admin API yet. Set this in librechat.yaml.",
+ "com_config_reserved_key_error": "Keys starting with '$', and the names __proto__, constructor and prototype, can't be saved. Choose another key.",
"com_config_base_only_section": "Configured in librechat.yaml only. LibreChat ignores admin overrides for this section, so it is read-only here and shows the current librechat.yaml values.",
"com_config_yaml_only_section": "Read from librechat.yaml when LibreChat starts. Admin overrides have no effect, so this section is read-only here and shows the current librechat.yaml values.",
"com_config_read_only_empty": "Nothing here is set in librechat.yaml, so LibreChat's defaults apply.",
"com_config_read_only_show_fields": "Show all fields",
"com_config_read_only_hide_fields": "Hide fields",
"com_config_yaml_managed_field": "Managed in librechat.yaml until LibreChat applies admin overrides for this setting. Shown read-only.",
+ "com_config_startup_only_field_one": "LibreChat reads the setting below only when it starts, and only from librechat.yaml: admin overrides never take effect. Shown read-only.",
+ "com_config_startup_only_field_other": "LibreChat reads the {{count}} settings below only when it starts, and only from librechat.yaml: admin overrides never take effect. Shown read-only.",
"com_config_stale_override": "A stored admin override for this setting has no effect.",
"com_config_stale_override_pending": "The stale override will be removed when you save.",
"com_config_remove_stale_override": "Remove stale override",
@@ -2025,6 +2040,7 @@
"com_config_field_jwksUri": "JWKS URI",
"com_config_features_cred_default": "Not set here: LibreChat reads the {{env}} environment variable on the server.",
"com_config_features_cred_env": "Read from the {{env}} environment variable on the server.",
+ "com_config_features_cred_env_only": "This LibreChat server reads web search settings only from environment variable references such as {{ref}}, so a value entered here is ignored. Reference an environment variable instead.",
"com_config_features_cred_key_placeholder": "Paste a key, or {{ref}}",
"com_config_features_cred_missing": "{{service}} needs this. Paste a value, or reference an environment variable such as {{ref}}.",
"com_config_features_cred_pending": "Will be stored encrypted when you save.",
@@ -2943,6 +2959,8 @@
"com_users_role_label": "Role",
"com_users_delete_title": "Delete user",
"com_users_delete_desc": "Are you sure you want to delete {{name}}? This action cannot be undone.",
+ "com_users_delete_failed": "The user could not be deleted.",
+ "com_users_delete_unavailable": "This LibreChat server doesn't allow deleting users through the admin API yet, so the user was not deleted.",
"com_users_no_assignments": "No roles or groups assigned",
"com_users_assign_role": "Add to role…",
"com_users_assign_group": "Add to group…",
@@ -3028,6 +3046,8 @@
"com_access_group_name_placeholder": "e.g. Engineering",
"com_access_group_desc_placeholder": "Optional description…",
"com_access_role_name_placeholder": "e.g. Content Moderator",
+ "com_access_rename_profile_warning": "This role has a configuration profile, stored under \"{{name}}\". Renaming the role leaves the profile there: it stops applying to the role's members, and a role created later with that name would get it.",
+ "com_access_rename_profile_moves": "Its configuration profile is renamed with it.",
"com_access_role_desc_placeholder": "Optional description…",
"com_access_active": "Active",
"com_access_paused": "Paused",
diff --git a/src/routes/_app.tsx b/src/routes/_app.tsx
index deab9e03..1f7f741f 100644
--- a/src/routes/_app.tsx
+++ b/src/routes/_app.tsx
@@ -3,6 +3,7 @@ import { Icon } from '@clickhouse/click-ui';
import { createFileRoute, Outlet, useRouter, Link, redirect } from '@tanstack/react-router';
import type { ErrorComponentProps } from '@tanstack/react-router';
import { useCapabilities, useCommandMenu, useLocalize } from '@/hooks';
+import { ConfigContractProvider } from '@/contexts/ContractContext';
import { CommandMenu } from '@/components/CommandMenu';
import { AccessDenied } from '@/components/shared';
import { SystemCapabilities } from '@/constants';
@@ -79,16 +80,18 @@ function AppLayout() {
const title = matchedKey ? localize(ROUTE_TITLE_KEYS[matchedKey]) : undefined;
return (
-
-
-
-
setOpen(true)} />
-
-
-
+
+
+
+
+ setOpen(true)} />
+
+
+
+
+
-
-
+
);
}
diff --git a/src/server/config.test.ts b/src/server/config.test.ts
index bb038361..e025563a 100644
--- a/src/server/config.test.ts
+++ b/src/server/config.test.ts
@@ -78,39 +78,60 @@ function findField(fields: t.SchemaField[], key: string): t.SchemaField | undefi
}
describe('applyLangfuseSchemaVisibility', () => {
+ const langfuseTree = () =>
+ extractSchemaTree(z3.object({ langfuse: z3.object({ schemaOnlyField: z3.string() }) }));
+
it('removes a Langfuse section supplied by the schema when fanout is disabled', () => {
const tree = extractSchemaTree(z3.object({ langfuse: z3.object({ enabled: z3.boolean() }) }));
- applyLangfuseSchemaVisibility(tree, false);
+ applyLangfuseSchemaVisibility(tree, { langfuseFanoutEnabled: false });
expect(findField(tree, 'langfuse')).toBeUndefined();
});
it('preserves the schema-provided Langfuse section when fanout is enabled', () => {
- const tree = extractSchemaTree(
- z3.object({ langfuse: z3.object({ schemaOnlyField: z3.string() }) }),
- );
+ const tree = langfuseTree();
- applyLangfuseSchemaVisibility(tree, true);
+ applyLangfuseSchemaVisibility(tree, { langfuseFanoutEnabled: true });
expect(findField(tree, 'schemaOnlyField')).toBeDefined();
expect(tree.filter((field) => field.key === 'langfuse')).toHaveLength(1);
});
it('preserves a schema-provided Langfuse section when fanout state is unknown', () => {
- const tree = extractSchemaTree(
- z3.object({ langfuse: z3.object({ schemaOnlyField: z3.string() }) }),
- );
+ const tree = langfuseTree();
applyLangfuseSchemaVisibility(tree, undefined);
expect(findField(tree, 'schemaOnlyField')).toBeDefined();
});
+ it('hides the section when LibreChat denies the admin connection access, fanout on or not', () => {
+ const tree = langfuseTree();
+
+ applyLangfuseSchemaVisibility(tree, {
+ langfuseFanoutEnabled: true,
+ langfuseConnectionAccess: false,
+ });
+
+ expect(findField(tree, 'langfuse')).toBeUndefined();
+ });
+
+ it('keeps the section when LibreChat grants connection access', () => {
+ const tree = langfuseTree();
+
+ applyLangfuseSchemaVisibility(tree, {
+ langfuseFanoutEnabled: true,
+ langfuseConnectionAccess: true,
+ });
+
+ expect(findField(tree, 'schemaOnlyField')).toBeDefined();
+ });
+
it('keeps the Langfuse section shipped in the pinned configSchema', () => {
const tree = extractSchemaTree(realConfigSchema);
- applyLangfuseSchemaVisibility(tree, true);
+ applyLangfuseSchemaVisibility(tree, { langfuseFanoutEnabled: true });
expect(findField(tree, 'langfuse')?.isObject).toBe(true);
expect(findField(tree, 'destination')?.path).toBe('langfuse.destination');
@@ -1331,7 +1352,7 @@ function preflight(saves: t.SaveEntry[], resets: string[] = []) {
...saves.map(({ fieldPath, value }): t.ConfigChange => ({ op: 'set', path: fieldPath, value })),
...resets.map((path): t.ConfigChange => ({ op: 'reset', path })),
];
- const plan = planConfigChanges(ops, { stored: {}, lower: {}, lowerKind: 'yaml' });
+ const plan = planConfigChanges(ops, { stored: {}, tombstones: [], lower: {}, lowerKind: 'yaml' });
return { issues: plan.issues, skipped: plan.skipped };
}
diff --git a/src/server/config/index.ts b/src/server/config/index.ts
index d478a8e8..229b4d2c 100644
--- a/src/server/config/index.ts
+++ b/src/server/config/index.ts
@@ -3,3 +3,4 @@ export * from './base';
export * from './normalize';
export * from './save';
export * from './import';
+export * from './meta';
diff --git a/src/server/config/meta.test.ts b/src/server/config/meta.test.ts
new file mode 100644
index 00000000..169731bd
--- /dev/null
+++ b/src/server/config/meta.test.ts
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { BUNDLED_CONFIG_RULES, BUNDLED_CONFIG_LIMITS, BUNDLED_CONFIG_CONTRACT } from '@/constants';
+
+const apiFetchMock = vi.fn();
+
+vi.mock('../utils/api', () => ({
+ apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init),
+}));
+
+vi.mock('@tanstack/react-start', () => ({
+ createServerFn: () => ({ handler: (fn: (...args: never[]) => unknown) => fn }),
+}));
+
+import { getAdminConfigMetaFn, readConfigContract } from './meta';
+
+/** `GET /api/admin/config/meta` as a LibreChat server with the contract answers it (abridged). */
+const META = {
+ metaVersion: 1,
+ versions: { librechat: 'v0.8.9-rc1', dataProvider: '0.8.528', configSchema: '1.3.18' },
+ features: { dottedKeys: true, webSearchStoredKeys: true, roleRenameMovesProfile: true },
+ limits: { maxPatchEntries: 100, maxChangeOps: 1000, overrideCacheTtlMs: 60000 },
+ rules: {
+ arrayMergeKeys: { 'endpoints.custom': 'name', 'endpoints.azureOpenAI.groups': 'group' },
+ recordSecretFields: [
+ { recordPath: 'mcpServers', secretPath: 'apiKey.key', previewPath: 'apiKey.keyPreview' },
+ ],
+ titleResolution: 'field',
+ },
+};
+
+function reply(status: number, body?: object): Response {
+ return new Response(body ? JSON.stringify(body) : null, { status });
+}
+
+beforeEach(() => {
+ apiFetchMock.mockReset();
+});
+
+describe('readConfigContract', () => {
+ it("reads the server's features, limits and rules", async () => {
+ apiFetchMock.mockResolvedValue(reply(200, META));
+ const contract = await readConfigContract();
+ expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/config/meta', undefined);
+ expect(contract.source).toBe('server');
+ expect(contract.versions).toEqual(META.versions);
+ expect(contract.features).toMatchObject({
+ dottedKeys: true,
+ webSearchStoredKeys: true,
+ roleRenameMovesProfile: true,
+ changesBatch: false,
+ });
+ expect(contract.limits).toEqual(META.limits);
+ expect(contract.rules.arrayMergeKeys).toEqual(META.rules.arrayMergeKeys);
+ expect(contract.rules.recordSecretFields).toEqual(META.rules.recordSecretFields);
+ expect(contract.rules.titleResolution).toBe('field');
+ expect(contract.rules.processMcpFields).toEqual(BUNDLED_CONFIG_RULES.processMcpFields);
+ });
+
+ it.each([
+ ['an older server without the route', 404],
+ ['a caller without config read access', 403],
+ ['an expired session', 401],
+ ['a failing server', 500],
+ ])('falls back to the bundled contract for %s', async (_case, status) => {
+ apiFetchMock.mockResolvedValue(reply(status, { message: 'Endpoint not found' }));
+ await expect(readConfigContract()).resolves.toBe(BUNDLED_CONFIG_CONTRACT);
+ });
+
+ it('falls back to the bundled contract when the request throws', async () => {
+ apiFetchMock.mockRejectedValue(new Error('No admin session token available'));
+ await expect(readConfigContract()).resolves.toBe(BUNDLED_CONFIG_CONTRACT);
+ });
+
+ it('reads a malformed part as not reported, keeping the rest', async () => {
+ apiFetchMock.mockResolvedValue(
+ reply(200, {
+ features: { dottedKeys: 'yes', webSearchStoredKeys: true },
+ limits: { maxPatchEntries: 0, overrideCacheTtlMs: 5000 },
+ rules: { titleResolution: 'sometimes', arrayMergeKeys: ['endpoints.custom'] },
+ }),
+ );
+ const contract = await readConfigContract();
+ expect(contract.features.dottedKeys).toBe(false);
+ expect(contract.features.webSearchStoredKeys).toBe(true);
+ expect(contract.limits).toEqual({ ...BUNDLED_CONFIG_LIMITS, overrideCacheTtlMs: 5000 });
+ expect(contract.rules.titleResolution).toBe('block');
+ expect(contract.rules.arrayMergeKeys).toEqual(BUNDLED_CONFIG_RULES.arrayMergeKeys);
+ });
+
+ it('treats a reply that is not an object as no contract', async () => {
+ apiFetchMock.mockResolvedValue(new Response('"ok"', { status: 200 }));
+ await expect(readConfigContract()).resolves.toBe(BUNDLED_CONFIG_CONTRACT);
+ });
+});
+
+describe('getAdminConfigMetaFn', () => {
+ it('returns the contract readConfigContract resolves', async () => {
+ apiFetchMock.mockResolvedValue(reply(404));
+ await expect(getAdminConfigMetaFn()).resolves.toBe(BUNDLED_CONFIG_CONTRACT);
+ });
+});
diff --git a/src/server/config/meta.ts b/src/server/config/meta.ts
new file mode 100644
index 00000000..8be121a1
--- /dev/null
+++ b/src/server/config/meta.ts
@@ -0,0 +1,96 @@
+import { z } from 'zod';
+import { queryOptions } from '@tanstack/react-query';
+import { createServerFn } from '@tanstack/react-start';
+import type * as t from '@/types';
+import { resolveConfigContract } from '@/utils';
+import { apiFetch } from '../utils/api';
+
+/** A part of the reply that is missing or malformed reads as not reported (the bundled value applies). */
+function lenient
(schema: T): z.ZodCatch> {
+ return schema.optional().catch(undefined);
+}
+
+const text = z.string();
+const names = z.array(z.string());
+const count = z.number().int().nonnegative();
+const nameMap = z.record(z.string(), z.string());
+
+const rulesSchema = z.object({
+ acceptedSections: lenient(names),
+ baseOnlySections: lenient(names),
+ basePrincipalSections: lenient(names),
+ baseOnlyPaths: lenient(names),
+ arrayMergeKeys: lenient(nameMap),
+ atomicMergePaths: lenient(names),
+ overrideKeyMap: lenient(nameMap),
+ secretFields: lenient(
+ z.array(z.object({ path: text, previewPath: text, allowEnvPlaceholder: z.boolean() })),
+ ),
+ arraySecretFields: lenient(
+ z.array(z.object({ arrayPath: text, secretKey: text, previewKey: text, identityKey: text })),
+ ),
+ recordSecretFields: lenient(
+ z.array(z.object({ recordPath: text, secretPath: text, previewPath: text })),
+ ),
+ refusedMaps: lenient(names),
+ processMcpFields: lenient(names),
+ interface: lenient(
+ z.object({ permissionFields: names, runtimeFields: names, permissionSubKeys: names }),
+ ),
+ titleResolution: lenient(z.enum(['block', 'field'])),
+});
+
+/** `GET /api/admin/config/meta`, read leniently: forks and newer servers may add, drop or reshape parts. */
+const metaSchema = z.object({
+ versions: lenient(
+ z.object({ librechat: text, dataProvider: text, configSchema: text }).partial(),
+ ),
+ features: lenient(z.record(z.string(), z.boolean().catch(false))),
+ limits: lenient(
+ z.object({
+ maxPatchEntries: lenient(z.number().int().positive()),
+ maxChangeOps: lenient(count),
+ overrideCacheTtlMs: lenient(count),
+ }),
+ ),
+ rules: lenient(rulesSchema),
+});
+
+/** Statuses of a server without the meta route (404) or a caller who may not read it (401, 403). */
+const NO_CONTRACT_STATUSES: ReadonlySet = new Set([401, 403, 404]);
+
+/**
+ * The connected LibreChat server's admin config contract. A server without
+ * `GET /api/admin/config/meta` (an older LibreChat), a caller who may not
+ * read it, and a reply that can't be read all get the bundled contract, so
+ * every behaviour stays as it was before the server reported one.
+ */
+export async function readConfigContract(): Promise {
+ try {
+ const response = await apiFetch('/api/admin/config/meta');
+ if (!response.ok) {
+ if (!NO_CONTRACT_STATUSES.has(response.status)) {
+ console.warn(`[readConfigContract] meta request failed: ${response.status}`);
+ }
+ return resolveConfigContract(null);
+ }
+ const parsed = metaSchema.safeParse(await response.json());
+ return resolveConfigContract(parsed.success ? parsed.data : null);
+ } catch (error) {
+ console.warn('[readConfigContract] meta request failed:', error);
+ return resolveConfigContract(null);
+ }
+}
+
+/** The admin config contract of the connected LibreChat server (see `readConfigContract`). */
+export const getAdminConfigMetaFn = createServerFn({ method: 'GET' }).handler(
+ (): Promise => readConfigContract(),
+);
+
+/** Shared query: every page reads the same contract, refreshed every few minutes. */
+export const configContractOptions = queryOptions({
+ queryKey: ['configContract'],
+ queryFn: () => getAdminConfigMetaFn(),
+ staleTime: 5 * 60_000,
+ retry: false,
+});
diff --git a/src/server/config/save.test.ts b/src/server/config/save.test.ts
index 565a09de..f9e21637 100644
--- a/src/server/config/save.test.ts
+++ b/src/server/config/save.test.ts
@@ -292,6 +292,27 @@ describe('applyConfigChanges — validated, ordered, compensated (SV-2)', () =>
expect(api.current!.stored()).toBeUndefined();
});
+ it('words a backend issue it gets no parameters for without placeholders', async () => {
+ api.current!.setOverride((request) =>
+ request.method === 'PATCH'
+ ? {
+ status: 400,
+ body: {
+ error: 'Invalid config override',
+ code: 'CONFIG_OVERRIDE_INVALID',
+ issues: [{ path: 'balance.startBalance', code: 'too_small' }],
+ },
+ }
+ : undefined,
+ );
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 5 },
+ ]);
+ expect(result.failed).toEqual([
+ { path: 'balance.startBalance', code: 'too_small', message: 'is too small' },
+ ]);
+ });
+
it('blocks provider: openai with the field and entry named', async () => {
const gpt = customEntries[1];
const result = await applyConfigChanges(base, [
@@ -325,6 +346,15 @@ describe('applyConfigChanges — clear/reset and verification (SV-4, SV-5, SV-7)
expect(api.current!.stored()).toEqual({});
});
+ it('keeps a stored tombstone when removing an override would empty its section (BE-11)', async () => {
+ api.current!.seed('role/__base__', { balance: { startBalance: 5 } }, ['balance.refillAmount']);
+ await applyConfigChanges(base, [{ op: 'reset', path: 'balance.startBalance' }]);
+ expect(api.current!.writes()).toMatchObject([
+ { method: 'DELETE', path: expect.stringContaining('fieldPath=balance.startBalance') },
+ ]);
+ expect(api.current!.tombstones()).toEqual(['balance.refillAmount']);
+ });
+
it('reports a value LibreChat dropped as not stored', async () => {
api.current!.stripSection('speech');
const result = await applyConfigChanges(base, [
@@ -440,3 +470,51 @@ describe('applyConfigChanges — switching a union away from a librechat.yaml ke
expect(api.current.writes()).toEqual([]);
});
});
+
+describe('applyConfigChanges — the server contract (GET /api/admin/config/meta)', () => {
+ const profiles = { 'us.anthropic.claude-sonnet-4-6': 'arn:aws:bedrock:us:1:profile/x' };
+
+ it('blocks a dotted record key on a server without the contract (it would drop the key)', async () => {
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'endpoints.bedrock.inferenceProfiles', value: profiles },
+ ]);
+ expect(result.issues.map((issue) => issue.code)).toEqual(['UNSAFE_KEY']);
+ expect(api.current!.writes()).toEqual([]);
+ expect(api.current!.requests.map((request) => request.path)).toContain(
+ '/api/admin/config/meta',
+ );
+ });
+
+ it('writes a dotted record whole when the server keeps dotted keys', async () => {
+ api.current!.reportMeta({ features: { dottedKeys: true } });
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'endpoints.bedrock.inferenceProfiles', value: profiles },
+ ]);
+ expect(result).toMatchObject({ issues: [], failed: [], applied: 1, skipped: [] });
+ expect(api.current!.writes()).toMatchObject([
+ {
+ method: 'PATCH',
+ body: { entries: [{ fieldPath: 'endpoints.bedrock.inferenceProfiles', value: profiles }] },
+ },
+ ]);
+ });
+
+ it('still refuses a $ key when the server keeps dotted keys', async () => {
+ api.current!.reportMeta({ features: { dottedKeys: true } });
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'endpoints.bedrock.inferenceProfiles', value: { $where: 'x' } },
+ ]);
+ expect(result.issues.map((issue) => issue.code)).toEqual(['RESERVED_KEY']);
+ expect(api.current!.writes()).toEqual([]);
+ });
+
+ it("splits sets into PATCHes of the server's maxPatchEntries", async () => {
+ api.current!.reportMeta({ limits: { maxPatchEntries: 2 } });
+ await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 5 },
+ { op: 'set', path: 'balance.refillAmount', value: 6 },
+ { op: 'set', path: 'balance.refillIntervalValue', value: 7 },
+ ]);
+ expect(api.current!.writes().map((request) => request.body?.entries?.length)).toEqual([2, 1]);
+ });
+});
diff --git a/src/server/config/save.ts b/src/server/config/save.ts
index 0ffc5559..7f0bcd79 100644
--- a/src/server/config/save.ts
+++ b/src/server/config/save.ts
@@ -2,7 +2,6 @@ import { z } from 'zod';
import { createServerFn } from '@tanstack/react-start';
import { PrincipalType } from 'librechat-data-provider';
import { SystemCapabilities } from '@librechat/data-schemas/capabilities';
-import type { AdminConfigResponse } from '@librechat/data-schemas';
import type * as t from '@/types';
import {
verifyWrite,
@@ -13,27 +12,31 @@ import {
getKeyedCollection,
getWriteSkipReason,
describeIssueCode,
+ paramlessIssueCode,
stripSecretPreviewValues,
} from '@/utils';
+import {
+ getConfigSection,
+ MERGED_BACK_REASONS,
+ TERMS_NOT_ACCEPTED_CODE,
+ TERMS_NOT_ACCEPTED_ISSUE,
+} from '@/constants';
import {
requireCapability,
requireAnyCapability,
requireAllSectionCapabilities,
} from '../capabilities';
-import { getConfigSection, TERMS_NOT_ACCEPTED_CODE, TERMS_NOT_ACCEPTED_ISSUE } from '@/constants';
import { apiErrorMessage, apiFetch } from '../utils/api';
import { BASE_CONFIG_PRINCIPAL_ID } from '../constants';
import { MISSING_SCOPE_MESSAGE } from '../scopes/read';
import { normalizeAppServiceKeys } from './normalize';
import { safeFieldPath } from '../utils/validation';
import { getApiBaseUrl } from '../utils/url';
+import { readConfigContract } from './meta';
import { getSchemaPathSet } from './schema';
export { parseIndexedArrayPath } from '@/utils';
-/** The admin API accepts at most this many entries per PATCH. */
-const MAX_PATCH_ENTRIES = 100;
-
/** How long to wait before re-reading the effective config once (LibreChat clears its caches asynchronously). */
const VERIFY_RETRY_MS = 400;
@@ -64,18 +67,28 @@ async function readJson(response: Response): Promise {
}
/**
- * The target's stored overrides (redacted by the admin API); `{}` when base
- * has no config yet. A profile whose document is gone is refused (PR-3): the
- * admin API would otherwise silently recreate it, active and at the default
- * priority.
+ * The target's stored overrides (redacted by the admin API) and tombstones;
+ * none when base has no config yet. A profile whose document is gone is
+ * refused (PR-3): the admin API would otherwise silently recreate it, active
+ * and at the default priority.
*/
-async function readStoredOverrides(target: t.WriteTargetRef): Promise {
+async function readStoredLayer(
+ target: t.WriteTargetRef,
+): Promise> {
const response = await apiFetch(targetConfigUrl(target));
if (response.status === 404 && target.type === 'scope') throw new Error(MISSING_SCOPE_MESSAGE);
- if (response.status === 404) return {};
+ if (response.status === 404) return { stored: {}, tombstones: [] };
if (!response.ok) throw new Error(`Failed to read the stored config: ${response.status}`);
- const { config } = await readJson(response);
- return normalizeAppServiceKeys((config?.overrides ?? {}) as t.ConfigRecord);
+ const { config } = await readJson(response);
+ return {
+ stored: normalizeAppServiceKeys((config?.overrides ?? {}) as t.ConfigRecord),
+ tombstones: config?.tombstones ?? [],
+ };
+}
+
+/** The target's stored overrides (see `readStoredLayer`). */
+async function readStoredOverrides(target: t.WriteTargetRef): Promise {
+ return (await readStoredLayer(target)).stored;
}
/** The base config: merged with the base overrides, or librechat.yaml alone (`baseOnly`). */
@@ -88,13 +101,13 @@ async function readBaseConfig(baseOnly: boolean): Promise {
/**
* The layers a write is planned against, read fresh: the target's stored
- * overrides and the layer beneath it (librechat.yaml for base, the merged
- * base config for a profile).
+ * overrides and tombstones and the layer beneath it (librechat.yaml for base,
+ * the merged base config for a profile).
*/
async function readWriteSnapshot(target: t.WriteTargetRef): Promise {
const isBase = target.type === 'base';
- const [stored, lower] = await Promise.all([readStoredOverrides(target), readBaseConfig(isBase)]);
- return { stored, lower, lowerKind: isBase ? 'yaml' : 'inherited' };
+ const [own, lower] = await Promise.all([readStoredLayer(target), readBaseConfig(isBase)]);
+ return { ...own, lower, lowerKind: isBase ? 'yaml' : 'inherited' };
}
async function authorizeWrite(target: t.WriteTargetRef, ops: t.ConfigChange[]): Promise {
@@ -137,10 +150,12 @@ function sanitizeOps(ops: t.ConfigChange[]): t.ConfigChange[] {
/**
* A rejected value the admin API reported by path. Paths index into the
* array that was sent, so an index inside a keyed collection is mapped to the
- * identity of that sent entry.
+ * identity of that sent entry. The admin API reports codes without their
+ * parameters, so a code whose message needs one (`too_small`'s minimum) is
+ * worded without it.
*/
function attributeBackendIssue(path: string, code: string, batch: t.SaveEntry[]): t.ConfigIssue {
- const message = describeIssueCode(code, undefined, 'is not a valid value');
+ const message = describeIssueCode(paramlessIssueCode(code), undefined, 'is not a valid value');
const sent = batch.find(
({ fieldPath }) => path === fieldPath || path.startsWith(`${fieldPath}.`),
);
@@ -183,14 +198,16 @@ interface WriteOutcome {
failure?: t.ConfigIssue[];
}
+/** Sets in PATCHes of at most the server's `maxPatchEntries` entries each. */
async function patchFields(
target: t.WriteTargetRef,
entries: t.SaveEntry[],
outcome: WriteOutcome,
+ maxEntries: number,
): Promise {
const priority = target.type === 'base' ? { priority: 0 } : {};
- for (let i = 0; i < entries.length; i += MAX_PATCH_ENTRIES) {
- const batch = entries.slice(i, i + MAX_PATCH_ENTRIES);
+ for (let i = 0; i < entries.length; i += maxEntries) {
+ const batch = entries.slice(i, i + maxEntries);
const response = await apiFetch(`${targetConfigUrl(target)}/fields`, {
method: 'PATCH',
body: JSON.stringify({ entries: batch, ...priority }),
@@ -255,11 +272,15 @@ async function writeFieldOps(
* Sets precede resets, so a rename writes the new entry before removing the
* old one. Stops at the first failure.
*/
-async function executePlan(target: t.WriteTargetRef, plan: t.WritePlan): Promise {
+async function executePlan(
+ target: t.WriteTargetRef,
+ plan: t.WritePlan,
+ contract: t.ConfigContract,
+): Promise {
const outcome: WriteOutcome = { writes: [], dropped: [] };
const steps = [
() => writeFieldOps(target, 'tombstone', plan.tombstones, outcome),
- () => patchFields(target, plan.sets, outcome),
+ () => patchFields(target, plan.sets, outcome, contract.limits.maxPatchEntries),
() => writeFieldOps(target, 'reset', plan.resets, outcome),
() => writeFieldOps(target, 'untombstone', plan.untombstones, outcome),
];
@@ -358,7 +379,8 @@ function hasWrites(plan: t.WritePlan): boolean {
/**
* The one write path for config changes (SV-1/SV-2/SV-4). Reads the target's
- * stored overrides and the layer beneath it, plans the changes against them
+ * stored overrides and the layer beneath it, and the server's contract
+ * (`readConfigContract`), plans the changes against them
* (`planConfigChanges`: keyed collection ops by identity, validation of
* fields, entries and whole sections, write rules), and writes nothing when
* any issue is found. Otherwise it writes tombstones, sets, then resets;
@@ -371,8 +393,8 @@ export async function applyConfigChanges(
ops: t.ConfigChange[],
): Promise {
await authorizeWrite(target, ops);
- const snapshot = await readWriteSnapshot(target);
- const plan = planConfigChanges(sanitizeOps(ops), snapshot);
+ const [snapshot, contract] = await Promise.all([readWriteSnapshot(target), readConfigContract()]);
+ const plan = planConfigChanges(sanitizeOps(ops), snapshot, contract.features);
const result: t.ApplyConfigChangesResult = {
applied: 0,
failed: [],
@@ -381,7 +403,7 @@ export async function applyConfigChanges(
skipped: plan.skipped,
};
if (plan.issues.length > 0 || !hasWrites(plan)) return result;
- const outcome = await executePlan(target, plan);
+ const outcome = await executePlan(target, plan, contract);
if (outcome.failure) {
const compensated = await compensate(target, outcome.writes, snapshot.stored);
return { ...result, failed: outcome.failure, compensated };
@@ -389,7 +411,7 @@ export async function applyConfigChanges(
const notEffective = [...outcome.dropped, ...(await verifyTarget(target, plan, snapshot))];
const lost = new Set(
notEffective
- .filter((item) => item.reason !== 'yamlMerged' && item.reason !== 'inheritedMerged')
+ .filter((item) => !MERGED_BACK_REASONS.has(item.reason))
.map((item) => item.fieldPath),
);
return {
diff --git a/src/server/config/schema.ts b/src/server/config/schema.ts
index 0a6fb8fa..a9f4e99e 100644
--- a/src/server/config/schema.ts
+++ b/src/server/config/schema.ts
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query';
import { configSchema } from 'librechat-data-provider';
import { createServerFn } from '@tanstack/react-start';
+import type { TStartupConfig } from 'librechat-data-provider';
import type * as t from '@/types';
import {
unwrapSchema,
@@ -15,13 +16,19 @@ import {
import { RUNTIME_NUMBER_LIMITS } from '@/constants';
import { apiFetch } from '../utils/api';
-/** Hide the Langfuse section when LibreChat reports fanout is disabled. When the
- * fanout state is unknown (older backends), the schema-provided section is kept. */
+/**
+ * Hides the Langfuse section when LibreChat reports the signed-in admin can't
+ * use its connection settings (`langfuseConnectionAccess`: tracing or tenant
+ * export is off, credentials come from the environment, or the admin lacks
+ * the Langfuse capability). A backend without that flag is judged by
+ * `langfuseFanoutEnabled`; with neither, the schema-provided section is kept.
+ */
export function applyLangfuseSchemaVisibility(
tree: t.SchemaField[],
- fanoutEnabled: boolean | undefined,
+ startup: Pick | undefined,
): t.SchemaField[] {
- if (fanoutEnabled !== false) return tree;
+ const available = startup?.langfuseConnectionAccess ?? startup?.langfuseFanoutEnabled;
+ if (available !== false) return tree;
const langfuseIndex = tree.findIndex((section) => section.key === 'langfuse');
if (langfuseIndex >= 0) tree.splice(langfuseIndex, 1);
return tree;
@@ -568,9 +575,9 @@ export const getConfigSchemaFields = createServerFn({ method: 'GET' }).handler(a
const tree = extractSchemaTree(configSchema);
const startupConfigResponse = await apiFetch('/api/config');
const startupConfig = startupConfigResponse.ok
- ? ((await startupConfigResponse.json()) as { langfuseFanoutEnabled?: boolean })
+ ? ((await startupConfigResponse.json()) as TStartupConfig)
: undefined;
- applyLangfuseSchemaVisibility(tree, startupConfig?.langfuseFanoutEnabled);
+ applyLangfuseSchemaVisibility(tree, startupConfig);
for (const section of tree) {
if (section.key === 'interface' && section.children) {
section.children = filterInterfacePermissionChildren(section.children);
diff --git a/src/server/langfuse.test.ts b/src/server/langfuse.test.ts
index 159c17fa..acd96d40 100644
--- a/src/server/langfuse.test.ts
+++ b/src/server/langfuse.test.ts
@@ -36,7 +36,7 @@ const status = {
destinations: [{ key: 'eu', baseUrl: 'https://cloud.langfuse.com' }],
destination: 'eu',
publicKey: 'pk-lf-public',
- displaySecretKey: 'sk-lf-...515f',
+ secretKeyPreview: 'sk-lf-...515f',
};
beforeEach(() => {
diff --git a/src/server/langfuse.ts b/src/server/langfuse.ts
index 265a6392..f150f697 100644
--- a/src/server/langfuse.ts
+++ b/src/server/langfuse.ts
@@ -1,23 +1,9 @@
import { z } from 'zod';
import { createServerFn } from '@tanstack/react-start';
+import type { TLangfuseConnectionStatus } from 'librechat-data-provider';
import { requireAllSectionCapabilities } from './capabilities';
import { apiFetch, extractApiError } from './utils/api';
-export interface LangfuseDestinationOption {
- key: string;
- baseUrl: string;
-}
-
-export interface LangfuseConnectionStatus {
- configured: boolean;
- enabled: boolean;
- destinations: LangfuseDestinationOption[];
- destination?: string;
- publicKey?: string;
- displaySecretKey?: string;
- updatedAt?: string;
-}
-
export interface LangfuseConnectionTestResponse {
success: boolean;
message?: string;
@@ -39,19 +25,19 @@ const connectionTestInputSchema = connectionInputSchema.omit({ enabled: true });
* destination allowlist, encrypted-secret handling, and credential checks.
*/
export const getLangfuseConnectionFn = createServerFn({ method: 'GET' }).handler(
- async (): Promise => {
+ async (): Promise => {
await requireAllSectionCapabilities(['langfuse']);
const response = await apiFetch('/api/admin/langfuse/connection');
if (!response.ok) {
return extractApiError(response, 'Failed to read Langfuse connection');
}
- return (await response.json()) as LangfuseConnectionStatus;
+ return (await response.json()) as TLangfuseConnectionStatus;
},
);
export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' })
.inputValidator(connectionInputSchema)
- .handler(async ({ data }): Promise => {
+ .handler(async ({ data }): Promise => {
await requireAllSectionCapabilities(['langfuse']);
const response = await apiFetch('/api/admin/langfuse/connection', {
method: 'PUT',
@@ -60,7 +46,7 @@ export const updateLangfuseConnectionFn = createServerFn({ method: 'POST' })
if (!response.ok) {
return extractApiError(response, 'Failed to update Langfuse connection');
}
- return (await response.json()) as LangfuseConnectionStatus;
+ return (await response.json()) as TLangfuseConnectionStatus;
});
export const testLangfuseConnectionFn = createServerFn({ method: 'POST' })
diff --git a/src/server/users.test.ts b/src/server/users.test.ts
new file mode 100644
index 00000000..dd20a8fc
--- /dev/null
+++ b/src/server/users.test.ts
@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { USER_DELETE_UNAVAILABLE } from '@/constants';
+
+const apiFetchMock = vi.fn();
+
+vi.mock('./utils/api', () => ({
+ apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init),
+ extractApiError: async (response: Response, fallback: string) => {
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
+ throw new Error(body.error ?? `${fallback}: ${response.status}`);
+ },
+}));
+
+vi.mock('@tanstack/react-start', () => ({
+ createServerFn: () => ({
+ handler: (fn: (...args: never[]) => unknown) => fn,
+ inputValidator: () => ({ handler: (fn: (...args: never[]) => unknown) => fn }),
+ }),
+}));
+
+import { deleteUserFn } from './users';
+
+function reply(status: number, body: object): Response {
+ return new Response(JSON.stringify(body), { status });
+}
+
+const remove = () => deleteUserFn({ data: { id: 'u 1' } });
+
+beforeEach(() => {
+ apiFetchMock.mockReset();
+});
+
+describe('deleteUserFn', () => {
+ it('deletes the user through the admin users API', async () => {
+ apiFetchMock.mockResolvedValue(reply(200, { message: 'User deleted' }));
+ await expect(remove()).resolves.toBeUndefined();
+ expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/users/u%201', { method: 'DELETE' });
+ });
+
+ it("treats LibreChat's own user-not-found reply as already deleted", async () => {
+ apiFetchMock.mockResolvedValue(reply(404, { error: 'User not found' }));
+ await expect(remove()).resolves.toBeUndefined();
+ });
+
+ it('fails when the server has no delete route, instead of reporting a deletion', async () => {
+ apiFetchMock.mockResolvedValue(reply(404, { message: 'Endpoint not found' }));
+ await expect(remove()).rejects.toThrow(USER_DELETE_UNAVAILABLE);
+ });
+
+ it("fails with the admin API's message on any other refusal", async () => {
+ apiFetchMock.mockResolvedValue(reply(403, { error: 'Forbidden' }));
+ await expect(remove()).rejects.toThrow('Forbidden');
+ });
+});
diff --git a/src/server/users.ts b/src/server/users.ts
index c4ec8fd3..cebb62ef 100644
--- a/src/server/users.ts
+++ b/src/server/users.ts
@@ -12,6 +12,7 @@ import { createServerFn } from '@tanstack/react-start';
import type { AdminUserSearchResult } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import { apiFetch, extractApiError } from './utils/api';
+import { USER_DELETE_UNAVAILABLE } from '@/constants';
// ── Server functions ─────────────────────────────────────────────────
@@ -44,15 +45,29 @@ export const createUserFn = createServerFn({ method: 'POST' })
throw new Error('Not implemented: createUserFn');
});
+/** The admin users API's reply when the user to delete no longer exists. */
+const USER_NOT_FOUND_ERROR = 'User not found';
+
+/**
+ * Deletes a user. LibreChat's own "user not found" 404 means the user is
+ * already gone, which is success; any other 404 is a server without the
+ * delete route (LibreChat ships it disabled), reported as
+ * `USER_DELETE_UNAVAILABLE` so the page explains it instead of claiming the
+ * user was deleted.
+ */
export const deleteUserFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ id: z.string() }))
- .handler(async ({ data }) => {
+ .handler(async ({ data }): Promise => {
const response = await apiFetch(`/api/admin/users/${encodeURIComponent(data.id)}`, {
method: 'DELETE',
});
- if (!response.ok && response.status !== 404) {
- throw new Error(`Failed to delete user: ${response.status}`);
+ if (response.ok) return;
+ if (response.status === 404) {
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
+ if (body.error === USER_NOT_FOUND_ERROR) return;
+ throw new Error(USER_DELETE_UNAVAILABLE);
}
+ await extractApiError(response, 'Failed to delete user');
});
export const searchUsersFn = createServerFn({ method: 'GET' })
diff --git a/src/test/admin.ts b/src/test/admin.ts
index 8130bdbd..38388135 100644
--- a/src/test/admin.ts
+++ b/src/test/admin.ts
@@ -57,6 +57,7 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
const requests: FakeAdminRequest[] = [];
const strippedSections = new Set();
let override: FakeAdminOverride | undefined;
+ let meta: t.AdminConfigMetaBody | undefined;
const docKey = (principalType: string, principalId: string) =>
`${principalType}/${decodeURIComponent(principalId)}`;
@@ -106,6 +107,9 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
if (url.pathname === '/api/admin/grants/effective') {
return reply(200, { capabilities: ['manage:configs'] });
}
+ if (url.pathname === '/api/admin/config/meta') {
+ return meta ? reply(200, meta) : reply(404, { message: 'Endpoint not found' });
+ }
if (url.pathname === '/api/admin/config/base') {
const config = url.searchParams.get('baseOnly') === 'true' ? yaml : baseMerged();
return reply(200, { config: redact(config) });
@@ -173,6 +177,10 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
setOverride: (next?: FakeAdminOverride) => {
override = next;
},
+ /** Serves `GET /api/admin/config/meta` (a server with the contract); unset, it answers 404 like an older server. */
+ reportMeta: (body?: t.AdminConfigMetaBody) => {
+ meta = body;
+ },
/** Every write request (PATCH, DELETE, POST), in order. */
writes: () => requests.filter((r) => r.method !== 'GET'),
};
diff --git a/src/test/page.tsx b/src/test/page.tsx
index 0890b8bf..5115c9cd 100644
--- a/src/test/page.tsx
+++ b/src/test/page.tsx
@@ -131,7 +131,12 @@ function TabPage({ tab, tree, yaml, overrides, expandAll, state }: HarnessProps)
collections: edits.collectionEdits,
visibility: edits.visibilityEdits,
});
- return planConfigChanges(changes.ops, { stored: overrides, lower: yaml, lowerKind: 'yaml' });
+ return planConfigChanges(changes.ops, {
+ stored: overrides,
+ tombstones: [],
+ lower: yaml,
+ lowerKind: 'yaml',
+ });
};
return (
diff --git a/src/types/access.ts b/src/types/access.ts
index d489bf70..8b8498a4 100644
--- a/src/types/access.ts
+++ b/src/types/access.ts
@@ -59,3 +59,12 @@ export interface RolePermissionsPanelProps {
export interface RolesTabProps {
onCreateRole: () => void;
}
+
+/** The note under a role's name while it is renamed and the role has a configuration profile. */
+export interface RenameProfileNoteProps {
+ id: string;
+ /** The role's current name, which its profile is stored under. */
+ name: string;
+ /** LibreChat moves the profile with the role (`roleRenameMovesProfile`). */
+ moves: boolean;
+}
diff --git a/src/types/changes.ts b/src/types/changes.ts
index d17acf4d..66f8a069 100644
--- a/src/types/changes.ts
+++ b/src/types/changes.ts
@@ -114,6 +114,12 @@ export type ChangeKind =
/** One human-readable row of the change review (WS1 produces, WS7 renders). */
export interface ChangeSummaryItem {
path: string;
+ /**
+ * The segments of `path` when a record key in it holds a dot (an inference
+ * profile `us.anthropic.claude-sonnet-4-6`), which splitting `path` would
+ * break apart; Revert follows them.
+ */
+ segments?: string[];
/** Identity (e.g. name) of the collection entry the change belongs to. */
entryIdentity?: string;
kind: ChangeKind;
@@ -167,6 +173,8 @@ export type LowerLayerKind = 'yaml' | 'inherited';
export interface WriteSnapshot {
/** The target's own stored overrides (secrets redacted). */
stored: ConfigRecord;
+ /** Paths the target hides from the layer beneath (its stored tombstones). */
+ tombstones: readonly string[];
/** The layer beneath the target: librechat.yaml for base, the base config for a profile. */
lower: ConfigRecord;
lowerKind: LowerLayerKind;
diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts
index 00be13e3..66fae118 100644
--- a/src/types/config-ui.ts
+++ b/src/types/config-ui.ts
@@ -554,7 +554,14 @@ export interface ConfigWriteResult {
skipped: SkippedChange[];
}
+/** Why a field is managed only in librechat.yaml: read once at startup, or not re-processed after overrides. */
+export type ManagedNoticeKind = 'startup' | 'yaml';
+
export interface ManagedNoticeProps {
+ /** `startup`: LibreChat reads the field only at startup (`STARTUP_ONLY_FIELDS`); `yaml` by default. */
+ kind?: ManagedNoticeKind;
+ /** How many settings the notice covers (consecutive startup-only fields share one). */
+ count?: number;
/** The edit target holds its own (ignored) override for the field. */
hasOverride: boolean;
/** Removal of that override is already queued for the next save. */
diff --git a/src/types/contract.ts b/src/types/contract.ts
new file mode 100644
index 00000000..1be7a882
--- /dev/null
+++ b/src/types/contract.ts
@@ -0,0 +1,132 @@
+import type { ReactNode } from 'react';
+
+/**
+ * A behaviour of the admin config API that a LibreChat server reports in
+ * `GET /api/admin/config/meta`. Mirrors `AdminConfigFeatureFlag` of newer
+ * `@librechat/data-schemas` releases (the pinned one has no meta contract).
+ */
+export type ConfigFeatureFlag =
+ | 'dottedKeys'
+ | 'sectionAllowlist'
+ | 'readFilteredWriteResponses'
+ | 'preAuthBaseOverrides'
+ | 'webSearchStoredKeys'
+ | 'expectedRevision'
+ | 'prunesEmptyParents'
+ | 'awaitedInvalidation'
+ | 'changesBatch'
+ | 'yamlMcpHide'
+ | 'deterministicPrecedence'
+ | 'priorityRoute'
+ | 'principalCheck'
+ | 'roleRenameMovesProfile'
+ | 'recordSecrets'
+ | 'azureGroupSecrets'
+ | 'yamlSecretPreview'
+ | 'envAllowlist'
+ | 'secretRebinding';
+
+/** Which admin config behaviours the server has; a flag it does not report is `false`. */
+export type ConfigFeatures = Readonly>;
+
+export interface ConfigLimits {
+ /** Most entries one `PATCH /fields` accepts. */
+ maxPatchEntries: number;
+ /** Most ops one `POST /changes` accepts (`0` without `changesBatch`). */
+ maxChangeOps: number;
+ /** How long another LibreChat instance may serve resolved config from before a write. */
+ overrideCacheTtlMs: number;
+}
+
+/** A secret config field: encrypted at rest, omitted from reads, kept when a write omits it. */
+export interface ConfigSecretFieldRule {
+ path: string;
+ /** Masked-preview companion returned on reads; never writable. */
+ previewPath: string;
+ /** Whether a `${ENV_VAR}` reference is stored as is instead of encrypted. */
+ allowEnvPlaceholder: boolean;
+}
+
+/** A secret held by every item of an array, matched across writes by `identityKey`. */
+export interface ConfigArraySecretFieldRule {
+ arrayPath: string;
+ secretKey: string;
+ previewKey: string;
+ identityKey: string;
+}
+
+/** A secret every entry of a record keyed by entry name holds, such as each MCP server's. */
+export interface ConfigRecordSecretFieldRule {
+ recordPath: string;
+ secretPath: string;
+ previewPath: string;
+}
+
+export interface ConfigInterfaceRules {
+ /** Interface fields that are role permissions; overrides drop them. */
+ permissionFields: string[];
+ /** Permission fields whose boolean form is a runtime toggle overrides keep. */
+ runtimeFields: string[];
+ /** Sub-keys of an object-form permission field that are permission bits. */
+ permissionSubKeys: string[];
+}
+
+/** The write rules a server enforces, in `librechat.yaml` key names. */
+export interface ConfigRules {
+ /** Top-level sections the server's config schema defines. */
+ acceptedSections: string[];
+ /** Sections only `librechat.yaml` sets; writes to them are dropped. */
+ baseOnlySections: string[];
+ /** Sections only the base document holds, through their dedicated APIs. */
+ basePrincipalSections: string[];
+ /** Fields inside overridable sections that only `librechat.yaml` sets. */
+ baseOnlyPaths: string[];
+ /** Arrays merged item by item on a key field instead of replaced. */
+ arrayMergeKeys: Readonly>;
+ /** Objects an override replaces whole instead of merging into. */
+ atomicMergePaths: string[];
+ /** Override sections the resolved config holds under another name. */
+ overrideKeyMap: Readonly>;
+ secretFields: ConfigSecretFieldRule[];
+ arraySecretFields: ConfigArraySecretFieldRule[];
+ recordSecretFields: ConfigRecordSecretFieldRule[];
+ /** Maps of credentials admin writes are refused for. */
+ refusedMaps: string[];
+ /** MCP server fields that make a server process-backed; only `librechat.yaml` sets them. */
+ processMcpFields: string[];
+ interface: ConfigInterfaceRules;
+ /** Whether `endpoints.all` title settings apply as one block or per field. */
+ titleResolution: 'block' | 'field';
+}
+
+export interface ConfigVersions {
+ librechat: string;
+ dataProvider: string;
+ configSchema: string;
+}
+
+/**
+ * The admin config API contract of the connected LibreChat server: what it
+ * reports in `GET /api/admin/config/meta`, each part it leaves out (or an
+ * older server without the route) filled from the panel's bundled fallback.
+ */
+export interface ConfigContract {
+ /** `server`: read from the meta route; `bundled`: the server has none, or it could not be read. */
+ source: 'server' | 'bundled';
+ versions?: ConfigVersions;
+ features: ConfigFeatures;
+ limits: ConfigLimits;
+ rules: ConfigRules;
+}
+
+/** `GET /api/admin/config/meta` as parsed: every part optional, since forks and older servers differ. */
+export interface AdminConfigMetaBody {
+ versions?: Partial;
+ features?: Partial>;
+ limits?: Partial;
+ rules?: Partial;
+}
+
+export interface ConfigContractProviderProps {
+ children: ReactNode;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index ac163a90..bf52e879 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -3,6 +3,7 @@ export type * from './auth';
export type * from './changes';
export type * from './config';
export type * from './config-ui';
+export type * from './contract';
export type * from './endpoints';
export type * from './controls';
export type * from './features';
diff --git a/src/types/mcp.ts b/src/types/mcp.ts
index 1e042b28..6a4e8cb8 100644
--- a/src/types/mcp.ts
+++ b/src/types/mcp.ts
@@ -174,6 +174,8 @@ export interface McpEditorProps {
owned?: boolean;
/** In a profile: the server keys the profile sets or edits, for "Set values only". */
ownKeys?: ReadonlySet;
+ /** Whether a pending edit writes the server's `leaf` whole (a record such as `customUserVars`). */
+ isPendingWhole?: (leaf: string) => boolean;
editSessionId?: number;
}
diff --git a/src/types/review.ts b/src/types/review.ts
index 40e64d47..d4dedd07 100644
--- a/src/types/review.ts
+++ b/src/types/review.ts
@@ -262,21 +262,31 @@ export interface ReviewRowViewProps {
}
/** Why an imported key is left out (WS7). */
-export type ImportSkipReason = SkippedChange['reason'] | 'unsafeKey' | 'processMcp';
+export type ImportSkipReason =
+ | SkippedChange['reason']
+ | 'unsafeKey'
+ | 'reservedKey'
+ | 'dottedKey'
+ | 'dottedName'
+ | 'processMcp';
/** An imported leaf that will not be written, and why (WS7). */
export interface ImportSkippedKey {
fieldPath: string;
reason: ImportSkipReason;
- /** For `unsafeKey`: the key, held by the object at `fieldPath`. */
+ /** For `unsafeKey`, `reservedKey`, `dottedKey` and `dottedName`: the key, held by the object at `fieldPath`. */
key?: string;
}
/** Leaves of an imported YAML, and the keys the admin API can't store (WS7). */
export interface ImportLeaves {
leaves: FlatConfigMap;
- /** Keys containing `.` or starting with `$`, with the path of the object holding them. */
+ /** Keys the admin API can't store (`isUnsafeConfigKey`), with the path of the object holding them. */
unsafe: UnsafeConfigKey[];
+ /** With `dottedKeys`: dotted keys where the schema expects a setting name, not a free key. */
+ dotted: UnsafeConfigKey[];
+ /** With `dottedKeys`: dotted names in a record of setting groups (an MCP server), written field by field. */
+ dottedNames: UnsafeConfigKey[];
}
/** Imported leaves split into what can be written and what is left out (WS7). */
diff --git a/src/utils/changes.test.ts b/src/utils/changes.test.ts
index 60925502..45acf2fb 100644
--- a/src/utils/changes.test.ts
+++ b/src/utils/changes.test.ts
@@ -7,6 +7,7 @@ import {
parseIndexedArrayPath,
} from './changes';
import { validateEntry, validateSection, validateField } from './validation';
+import { BUNDLED_CONFIG_FEATURES } from '@/constants';
const yaml: t.ConfigRecord = {
balance: { enabled: true, startBalance: 1000000, refillAmount: 1000000 },
@@ -30,8 +31,12 @@ const yaml: t.ConfigRecord = {
},
};
-function snapshot(stored: t.ConfigRecord = {}, lower: t.ConfigRecord = yaml): t.WriteSnapshot {
- return { stored, lower, lowerKind: 'yaml' };
+function snapshot(
+ stored: t.ConfigRecord = {},
+ lower: t.ConfigRecord = yaml,
+ tombstones: string[] = [],
+): t.WriteSnapshot {
+ return { stored, tombstones, lower, lowerKind: 'yaml' };
}
describe('validateSection / validateEntry / validateField', () => {
@@ -163,6 +168,42 @@ describe('planConfigChanges', () => {
]);
});
+ describe('on a server that keeps dotted keys', () => {
+ const dotted: t.ConfigFeatures = { ...BUNDLED_CONFIG_FEATURES, dottedKeys: true };
+
+ it('sends a record with dotted keys whole, at its own path', () => {
+ const profiles = { 'us.anthropic.claude-sonnet-4-6': 'arn:aws:bedrock:x' };
+ const ops: t.ConfigChange[] = [
+ { op: 'set', path: 'endpoints.bedrock.inferenceProfiles', value: profiles },
+ ];
+ expect(planConfigChanges(ops, snapshot()).issues.map((issue) => issue.code)).toEqual([
+ 'UNSAFE_KEY',
+ ]);
+ const plan = planConfigChanges(ops, snapshot(), dotted);
+ expect(plan.issues).toEqual([]);
+ expect(plan.sets).toEqual([
+ { fieldPath: 'endpoints.bedrock.inferenceProfiles', value: profiles },
+ ]);
+ });
+
+ it('refuses $-prefixed and prototype keys as reserved', () => {
+ const plan = planConfigChanges(
+ [
+ {
+ op: 'set',
+ path: 'mcpServers.github.headers',
+ value: { $where: '1', 'X-Thread.Id': '2' },
+ },
+ ],
+ snapshot(),
+ dotted,
+ );
+ expect(plan.issues).toMatchObject([
+ { code: 'RESERVED_KEY', path: 'mcpServers.github.headers', params: { key: '$where' } },
+ ]);
+ });
+ });
+
it('names the entry of an unsafe key inside a stored endpoint list, not its stored position', () => {
const baseline = ((yaml.endpoints as t.ConfigRecord).custom as t.ConfigRecord[])[1];
const stored = {
@@ -452,6 +493,46 @@ describe('planConfigChanges', () => {
expect(plan.storedAfter).toEqual({});
});
+ it('never widens a reset past a parent with a stored tombstone under it', () => {
+ const plan = planConfigChanges(
+ [{ op: 'reset', path: 'balance.startBalance' }],
+ snapshot({ balance: { startBalance: 5 } }, yaml, ['balance.refillAmount']),
+ );
+ expect(plan.resets).toEqual(['balance.startBalance']);
+ });
+
+ it('never widens a reset past a tombstone the same batch writes', () => {
+ const plan = planConfigChanges(
+ [
+ { op: 'reset', path: 'balance.startBalance' },
+ { op: 'tombstone', path: 'balance.refillAmount' },
+ ],
+ snapshot({ balance: { startBalance: 5 } }),
+ );
+ expect(plan.resets).toEqual(['balance.startBalance']);
+ expect(plan.tombstones).toEqual(['balance.refillAmount']);
+ });
+
+ it('still widens over tombstones under the reset path itself, which the reset removes anyway', () => {
+ const plan = planConfigChanges(
+ [{ op: 'reset', path: 'mcpServers.github.headers' }],
+ snapshot({ mcpServers: { github: { headers: { 'X-A': '1' } } } }, yaml, [
+ 'mcpServers.github.headers.Authorization',
+ ]),
+ );
+ expect(plan.resets).toEqual(['mcpServers']);
+ });
+
+ it('widens up to the last parent without another tombstone under it', () => {
+ const plan = planConfigChanges(
+ [{ op: 'reset', path: 'endpoints.anthropic.models.default' }],
+ snapshot({ endpoints: { anthropic: { models: { default: ['claude-opus-5-5'] } } } }, yaml, [
+ 'endpoints.anthropic.titleModel',
+ ]),
+ );
+ expect(plan.resets).toEqual(['endpoints.anthropic.models']);
+ });
+
it('folds a reset with new descendant values into one set (rename onto the same key)', () => {
const stored = { mcpServers: { srv: { type: 'sse', url: 'https://old', title: 'Old' } } };
const plan = planConfigChanges(
@@ -503,21 +584,28 @@ describe('planConfigChanges', () => {
});
it('expands an item write of a plain array into the whole array', () => {
- const lower = {
- modelSpecs: { list: [{ name: 'a', label: 'A', preset: { endpoint: 'openAI' } }] },
- };
+ const lower = { endpoints: { agents: { allowedProviders: ['openAI', 'anthropic'] } } };
const plan = planConfigChanges(
- [
- {
- op: 'set',
- path: 'endpoints.agents.toolApproval.hooks.0',
- value: { module: 'm' },
- },
- ],
+ [{ op: 'set', path: 'endpoints.agents.allowedProviders.1', value: 'google' }],
snapshot({}, lower),
);
expect(plan.sets).toEqual([
- { fieldPath: 'endpoints.agents.toolApproval.hooks', value: [{ module: 'm' }] },
+ { fieldPath: 'endpoints.agents.allowedProviders', value: ['openAI', 'google'] },
+ ]);
+ });
+
+ it('never sends a setting LibreChat reads only at startup', () => {
+ const plan = planConfigChanges(
+ [
+ { op: 'set', path: 'endpoints.agents.toolApproval.hooks.0', value: { module: 'm' } },
+ { op: 'set', path: 'mcpSettings.catalogRecovery.maxStateEntries', value: 10 },
+ ],
+ snapshot(),
+ );
+ expect(plan.sets).toEqual([]);
+ expect(plan.skipped).toEqual([
+ { fieldPath: 'endpoints.agents.toolApproval.hooks.0', reason: 'yamlOnly' },
+ { fieldPath: 'mcpSettings.catalogRecovery.maxStateEntries', reason: 'yamlOnly' },
]);
});
@@ -560,6 +648,34 @@ describe('verifyWrite', () => {
]);
});
+ it('compares a record with dotted keys whole, without splitting its keys into paths', () => {
+ const headers = { 'x-portkey.config': 'cfg', 'X-Plain': '1' };
+ const entry = {
+ name: 'Portkey',
+ apiKey: 'user_provided',
+ baseURL: 'https://api.portkey.ai/v1',
+ models: { default: ['m'] },
+ headers,
+ };
+ const plan = planConfigChanges(
+ [
+ {
+ op: 'upsertEntry',
+ path: 'endpoints.custom',
+ key: 'Portkey',
+ identity: null,
+ value: entry,
+ },
+ ],
+ snapshot(),
+ { ...BUNDLED_CONFIG_FEATURES, dottedKeys: true },
+ );
+ const storedActual = { endpoints: { custom: [entry] } };
+ const yamlEntries = (yaml.endpoints as t.ConfigRecord).custom as t.ConfigRecord[];
+ const effective = { endpoints: { custom: [...yamlEntries, entry] } };
+ expect(verifyWrite(plan, snapshot(), storedActual, effective)).toEqual([]);
+ });
+
it('accepts redacted secrets and an effective value that differs from YAML', () => {
const plan = planConfigChanges(
[{ op: 'set', path: 'webSearch.tavilyApiKey', value: 'tvly-secret' }],
diff --git a/src/utils/changes.ts b/src/utils/changes.ts
index f92c2199..dfb87a77 100644
--- a/src/utils/changes.ts
+++ b/src/utils/changes.ts
@@ -13,6 +13,14 @@ import {
resolveCollectionOps,
findKeyedCollectionEntry,
} from './collections';
+import {
+ hasDottedKey,
+ isConfigRecord,
+ getValueAtPath,
+ setValueAtPath,
+ removeValueAtPath,
+ isConfigValueEqual,
+} from './format';
import {
validateEntry,
validateField,
@@ -24,21 +32,13 @@ import {
getConfigSection,
getReadOnlyReason,
SECRET_CONFIG_KEYS,
- BLOCK_UNSAFE_CONFIG_KEYS,
- UNSAFE_CONFIG_KEY_MESSAGE,
+ BUNDLED_CONFIG_FEATURES,
} from '@/constants';
-import {
- isConfigRecord,
- getValueAtPath,
- setValueAtPath,
- removeValueAtPath,
- isConfigValueEqual,
-} from './format';
import { isSecretKey, toSecretPreviewKey, valueOrSecretPreview } from './secrets';
import { findYamlManagedValues, getWriteSkipReason } from './writes';
+import { isBlankText, findBlankText, findUnsafeKeys } from './keys';
import { resolveSubSchema, unwrapSchema } from './schema';
import { scopeGateSkipReason } from './scopes';
-import { isBlankText, findBlankText, findUnsafeKeys } from './keys';
/** Whether a write at `path` sets a process-backed (stdio) MCP server field, which the admin API rejects. */
export function isProcessMcpWrite(path: string, value: t.ConfigValue): boolean {
@@ -53,11 +53,16 @@ export function isProcessMcpWrite(path: string, value: t.ConfigValue): boolean {
/**
* Write-rule problems of one value: YAML-managed fields, process-backed MCP
- * fields and unsafe keys. In a keyed collection's list, each problem names
+ * fields and keys the server can't store (dotted keys only before its
+ * `dottedKeys` feature). In a keyed collection's list, each problem names
* its entry, since the list's positions are not the ones the page shows.
*/
-export function findWriteRuleIssues(path: string, value: t.ConfigValue): t.ConfigIssue[] {
- const issues = findValueRuleIssues(path, value);
+export function findWriteRuleIssues(
+ path: string,
+ value: t.ConfigValue,
+ features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+): t.ConfigIssue[] {
+ const issues = findValueRuleIssues(path, value, features);
const collection = getKeyedCollection(path);
if (!collection || !Array.isArray(value) || issues.length === 0) return issues;
return issues.map((issue) => withEntryOf(issue, collection, value));
@@ -75,7 +80,11 @@ function withEntryOf(
return { ...issue, entryIdentity: identity, entryPath: located.rest };
}
-function findValueRuleIssues(path: string, value: t.ConfigValue): t.ConfigIssue[] {
+function findValueRuleIssues(
+ path: string,
+ value: t.ConfigValue,
+ features: t.ConfigFeatures,
+): t.ConfigIssue[] {
const managed = findYamlManagedValues(path, value);
if (managed.length > 0) {
return managed.map((managedPath) => ({
@@ -94,11 +103,11 @@ function findValueRuleIssues(path: string, value: t.ConfigValue): t.ConfigIssue[
];
}
const blank = [...blankPathKeys(path), ...findBlankText(value, path)].map(blankTextIssue);
- if (!BLOCK_UNSAFE_CONFIG_KEYS) return blank;
- const unsafe = findUnsafeKeys(value, path).map(({ path: holder, key }) => ({
+ const code = features.dottedKeys ? 'RESERVED_KEY' : 'UNSAFE_KEY';
+ const unsafe = findUnsafeKeys(value, path, features.dottedKeys).map(({ path: holder, key }) => ({
path: holder,
- code: 'UNSAFE_KEY',
- message: `"${key}": ${UNSAFE_CONFIG_KEY_MESSAGE}`,
+ code,
+ message: describeIssueCode(code, { key }, ''),
params: { key },
}));
return [...unsafe, ...blank];
@@ -129,6 +138,7 @@ function blankTextIssue({ path, key }: t.BlankConfigText): t.ConfigIssue {
interface PlanState {
snapshot: t.WriteSnapshot;
+ features: t.ConfigFeatures;
sets: Map;
resets: string[];
tombstones: string[];
@@ -407,12 +417,37 @@ function parentPath(path: string): string {
return path.slice(0, Math.max(0, path.lastIndexOf('.')));
}
+function isAtOrUnder(path: string, ancestor: string): boolean {
+ return path === ancestor || path.startsWith(`${ancestor}.`);
+}
+
+/**
+ * Whether removing `ancestor` would also remove a tombstone that removing
+ * `path` leaves: LibreChat drops every tombstone at or under a removed path,
+ * so widening a reset over one would show again a value the target hides.
+ */
+function holdsOtherTombstone(
+ ancestor: string,
+ path: string,
+ tombstones: readonly string[],
+): boolean {
+ return tombstones.some(
+ (tombstone) => isAtOrUnder(tombstone, ancestor) && !isAtOrUnder(tombstone, path),
+ );
+}
+
/**
* The highest ancestor a removal leaves empty, so no `{}` remains stored: a
* parent whose only key is the emptied child (`endpoints.anthropic.models`
* after its last key goes, then `endpoints.anthropic`, then `endpoints`).
+ * It never widens past an ancestor with another tombstone under it (stored,
+ * or written by this batch), which the wider removal would drop.
*/
-function emptiedAncestor(storedAfter: t.ConfigRecord, path: string): string {
+function emptiedAncestor(
+ storedAfter: t.ConfigRecord,
+ path: string,
+ tombstones: readonly string[],
+): string {
let current = path;
const leftEmpty = getValueAtPath(storedAfter, current);
if (isConfigRecord(leftEmpty) && Object.keys(leftEmpty).length > 0) return current;
@@ -421,6 +456,7 @@ function emptiedAncestor(storedAfter: t.ConfigRecord, path: string): string {
const value = getValueAtPath(storedAfter, parent);
const child = current.slice(parent.length + 1);
if (!isConfigRecord(value) || Object.keys(value).some((key) => key !== child)) break;
+ if (holdsOtherTombstone(parent, path, tombstones)) break;
current = parent;
parent = parentPath(current);
}
@@ -446,7 +482,8 @@ function applyToStored(state: PlanState): t.ConfigRecord {
/** Widens each reset to the highest parent it leaves empty, then returns the stored overrides afterwards. */
function pruneEmptyParents(state: PlanState): t.ConfigRecord {
const removed = applyToStored(state);
- const widened = state.resets.map((path) => emptiedAncestor(removed, path));
+ const tombstones = [...state.snapshot.tombstones, ...state.tombstones];
+ const widened = state.resets.map((path) => emptiedAncestor(removed, path, tombstones));
state.resets = [...new Set(widened)].filter((path) => !hasAncestorIn(path, widened));
return applyToStored(state);
}
@@ -504,7 +541,7 @@ function isIntroducedIssue(state: PlanState, path: string, issue: t.ConfigIssue)
function validateSets(state: PlanState): Set {
const blocked = new Set();
for (const [path, value] of state.sets) {
- const ruleIssues = findWriteRuleIssues(path, value).filter((issue) =>
+ const ruleIssues = findWriteRuleIssues(path, value, state.features).filter((issue) =>
isIntroducedIssue(state, path, issue),
);
if (ruleIssues.length > 0) {
@@ -601,16 +638,22 @@ function findMergedBackKeys(state: PlanState): void {
/**
* Resolves a batch of changes against the target's stored overrides and the
- * layer beneath it, into the admin API writes that implement it. Everything
+ * layer beneath it, into the admin API writes that implement it, by the
+ * server's contract `features` (the bundled ones: an older server). Everything
* is checked before anything may be written: write rules (read-only and
* YAML-managed paths, process-backed MCP fields, unsafe keys), field and
* entry schemas, whole sections, and the keyed collection rules. The client
* runs it for the review and inline errors; the server runs it again against
* freshly read layers right before writing.
*/
-export function planConfigChanges(ops: t.ConfigChange[], snapshot: t.WriteSnapshot): t.WritePlan {
+export function planConfigChanges(
+ ops: t.ConfigChange[],
+ snapshot: t.WriteSnapshot,
+ features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+): t.WritePlan {
const state: PlanState = {
snapshot,
+ features,
sets: new Map(),
resets: [],
tombstones: [],
@@ -659,9 +702,13 @@ function stripSecrets(value: t.ConfigValue): t.ConfigValue {
return Object.keys(result).length === 0 ? undefined : result;
}
-/** Leaf paths (relative) and values of a value; arrays are leaves, secrets are skipped. */
+/**
+ * Leaf paths (relative) and values of a value; arrays are leaves, and so is an
+ * object with a dotted key, which no path can split (`hasDottedKey`); secrets
+ * are skipped.
+ */
function leavesOf(value: t.ConfigValue, prefix = ''): Array<[string, t.ConfigValue]> {
- if (!isConfigRecord(value)) return [[prefix, value]];
+ if (!isConfigRecord(value) || hasDottedKey(value)) return [[prefix, value]];
return Object.entries(value).flatMap(([key, child]) =>
isRedactedKey(key) ? [] : leavesOf(child, prefix ? `${prefix}.${key}` : key),
);
diff --git a/src/utils/collections.ts b/src/utils/collections.ts
index 32346aaa..697850a8 100644
--- a/src/utils/collections.ts
+++ b/src/utils/collections.ts
@@ -1,12 +1,12 @@
import type * as t from '@/types';
import { isConfigRecord, getValueAtPath, isConfigValueEqual } from './format';
-import { KEYED_COLLECTIONS } from '@/constants';
+import { KEYED_COLLECTIONS, BUNDLED_CONFIG_RULES } from '@/constants';
type UpsertEntryOp = Extract;
type RemoveEntryOp = Extract;
/** Object paths LibreChat replaces whole instead of merging (its `ATOMIC_MERGE_PATHS`, in YAML keys). */
-const ATOMIC_MERGE_PATHS: ReadonlySet = new Set(['interface.theme']);
+const ATOMIC_MERGE_PATHS: ReadonlySet = new Set(BUNDLED_CONFIG_RULES.atomicMergePaths);
/** The keyed collection stored at exactly `path`. */
export function getKeyedCollection(path: string): t.KeyedCollection | undefined {
diff --git a/src/utils/contract.test.ts b/src/utils/contract.test.ts
new file mode 100644
index 00000000..cdf8f46f
--- /dev/null
+++ b/src/utils/contract.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest';
+import {
+ CONFIG_FEATURE_FLAGS,
+ BUNDLED_CONFIG_RULES,
+ BUNDLED_CONFIG_LIMITS,
+ BUNDLED_CONFIG_CONTRACT,
+} from '@/constants';
+import { resolveConfigContract } from './contract';
+
+describe('resolveConfigContract', () => {
+ it('is the bundled contract, every feature off, for a server without the meta route', () => {
+ const contract = resolveConfigContract(null);
+ expect(contract).toBe(BUNDLED_CONFIG_CONTRACT);
+ expect(contract.source).toBe('bundled');
+ expect(Object.values(contract.features).every((on) => on === false)).toBe(true);
+ expect(Object.keys(contract.features).sort()).toEqual([...CONFIG_FEATURE_FLAGS].sort());
+ });
+
+ it('turns on exactly the flags the server reports as true', () => {
+ const contract = resolveConfigContract({
+ features: { dottedKeys: true, webSearchStoredKeys: false, someFutureFlag: true },
+ });
+ expect(contract.source).toBe('server');
+ expect(contract.features.dottedKeys).toBe(true);
+ expect(contract.features.webSearchStoredKeys).toBe(false);
+ expect(contract.features.changesBatch).toBe(false);
+ expect(contract.features).not.toHaveProperty('someFutureFlag');
+ });
+
+ it('fills limits and rules the server leaves out from the bundled ones', () => {
+ const contract = resolveConfigContract({
+ limits: { maxPatchEntries: 50, maxChangeOps: undefined },
+ rules: {
+ titleResolution: 'field',
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ },
+ });
+ expect(contract.limits).toEqual({ ...BUNDLED_CONFIG_LIMITS, maxPatchEntries: 50 });
+ expect(contract.rules).toEqual({
+ ...BUNDLED_CONFIG_RULES,
+ titleResolution: 'field',
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ });
+ });
+
+ it('reports versions only when all three are given', () => {
+ const versions = { librechat: 'v0.8.9', dataProvider: '0.8.528', configSchema: '1.3.18' };
+ expect(resolveConfigContract({ versions }).versions).toEqual(versions);
+ expect(resolveConfigContract({ versions: { librechat: 'v0.8.9' } }).versions).toBeUndefined();
+ });
+});
+
+describe('BUNDLED_CONFIG_RULES', () => {
+ it('mirrors what LibreChat enforced before it reported its rules', () => {
+ expect(BUNDLED_CONFIG_RULES.arrayMergeKeys).toEqual({ 'endpoints.custom': 'name' });
+ expect(BUNDLED_CONFIG_RULES.arraySecretFields).toEqual([
+ {
+ arrayPath: 'endpoints.custom',
+ secretKey: 'apiKey',
+ previewKey: 'apiKeyPreview',
+ identityKey: 'name',
+ },
+ ]);
+ expect(BUNDLED_CONFIG_RULES.overrideKeyMap).toEqual({
+ interface: 'interfaceConfig',
+ turnstile: 'turnstileConfig',
+ mcpServers: 'mcpConfig',
+ });
+ expect(BUNDLED_CONFIG_RULES.baseOnlySections).toEqual(
+ expect.arrayContaining(['filters', 'mcpAppSandbox']),
+ );
+ expect(BUNDLED_CONFIG_RULES.recordSecretFields).toEqual([]);
+ expect(BUNDLED_CONFIG_RULES.titleResolution).toBe('block');
+ expect(BUNDLED_CONFIG_RULES.secretFields).toContainEqual({
+ path: 'langfuse.secretKey',
+ previewPath: 'langfuse.secretKeyPreview',
+ allowEnvPlaceholder: false,
+ });
+ });
+});
diff --git a/src/utils/contract.ts b/src/utils/contract.ts
new file mode 100644
index 00000000..46ac0c53
--- /dev/null
+++ b/src/utils/contract.ts
@@ -0,0 +1,44 @@
+import type * as t from '@/types';
+import {
+ CONFIG_FEATURE_FLAGS,
+ BUNDLED_CONFIG_RULES,
+ BUNDLED_CONFIG_LIMITS,
+ BUNDLED_CONFIG_CONTRACT,
+} from '@/constants';
+
+/**
+ * The contract a server reported, every part it leaves out filled from the
+ * bundled fallback: a flag it does not report is off, and a limit or rule it
+ * does not report is the one LibreChat enforced before it reported them.
+ * `null` (no meta route, or an unreadable reply) is the bundled contract.
+ */
+export function resolveConfigContract(body: t.AdminConfigMetaBody | null): t.ConfigContract {
+ if (!body) return BUNDLED_CONFIG_CONTRACT;
+ const reported = body.features ?? {};
+ const features = Object.fromEntries(
+ CONFIG_FEATURE_FLAGS.map((flag) => [flag, reported[flag] === true]),
+ ) as Record;
+ const versions = body.versions;
+ return {
+ source: 'server',
+ versions:
+ versions?.librechat != null && versions.dataProvider != null && versions.configSchema != null
+ ? {
+ librechat: versions.librechat,
+ dataProvider: versions.dataProvider,
+ configSchema: versions.configSchema,
+ }
+ : undefined,
+ features,
+ limits: { ...BUNDLED_CONFIG_LIMITS, ...definedOnly(body.limits) },
+ rules: { ...BUNDLED_CONFIG_RULES, ...definedOnly(body.rules) },
+ };
+}
+
+/** A copy without the keys whose value is `undefined`, so they don't hide the fallback's. */
+function definedOnly(value: T | undefined): Partial {
+ if (!value) return {};
+ return Object.fromEntries(
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
+ ) as Partial;
+}
diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts
index 19843bc0..6327dc19 100644
--- a/src/utils/format.test.ts
+++ b/src/utils/format.test.ts
@@ -1,5 +1,30 @@
import { describe, it, expect } from 'vitest';
-import { serializeKVPairs, deepSerializeKVPairs } from './format';
+import { hasDottedKey, flattenObject, serializeKVPairs, deepSerializeKVPairs } from './format';
+
+describe('flattenObject', () => {
+ it('flattens nested objects into dot-paths', () => {
+ expect(flattenObject({ balance: { startBalance: 5, refill: { amount: 1 } } })).toEqual({
+ 'balance.startBalance': 5,
+ 'balance.refill.amount': 1,
+ });
+ });
+
+ it('keeps an object with a dotted key as one leaf (a path would split the key)', () => {
+ const profiles = { 'us.anthropic.claude': 'arn:a', plain: 'arn:b' };
+ expect(flattenObject({ endpoints: { bedrock: { inferenceProfiles: profiles } } })).toEqual({
+ 'endpoints.bedrock.inferenceProfiles': profiles,
+ });
+ });
+});
+
+describe('hasDottedKey', () => {
+ it('is true only for an object with a key containing a dot', () => {
+ expect(hasDottedKey({ 'gpt-4.1': true })).toBe(true);
+ expect(hasDottedKey({ gpt4: true, nested: { 'a.b': 1 } })).toBe(false);
+ expect(hasDottedKey(['a.b'])).toBe(false);
+ expect(hasDottedKey('a.b')).toBe(false);
+ });
+});
describe('serializeKVPairs', () => {
it('converts KV pairs to a record', () => {
diff --git a/src/utils/format.ts b/src/utils/format.ts
index 5f6620de..aae36de4 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -62,9 +62,19 @@ export function getInitials(name: string): string {
return (parts[0]?.[0] ?? '').toUpperCase();
}
+/**
+ * Whether an object has a key containing `.` (a Bedrock inference profile ID,
+ * a MIME type, a dotted header name). A dot-path can't name such a key, so
+ * the object is a leaf of its tree: it is read and written whole.
+ */
+export function hasDottedKey(value: t.ConfigValue): boolean {
+ return isConfigRecord(value) && Object.keys(value).some((key) => key.includes('.'));
+}
+
/**
* Recursively flattens a nested object into dot-separated paths.
- * Plain objects become prefixes; arrays, primitives, and null become leaf entries.
+ * Plain objects become prefixes; arrays, primitives, and null become leaf entries,
+ * and so does an object with a dotted key (`hasDottedKey`), which no path can split.
* e.g. { balance: { startBalance: 100 } } → { 'balance.startBalance': 100 }
*/
export function flattenObject(obj: Record, prefix = ''): t.FlatConfigMap {
@@ -77,7 +87,8 @@ export function flattenObject(obj: Record, prefix = ''):
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
- Object.keys(value).length > 0
+ Object.keys(value).length > 0 &&
+ !hasDottedKey(value)
) {
Object.assign(result, flattenObject(value as Record, path));
} else {
diff --git a/src/utils/index.ts b/src/utils/index.ts
index ccda65c7..3e01aa92 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,6 +3,7 @@ export * from './capabilities';
export * from './changes';
export * from './cn';
export * from './collections';
+export * from './contract';
export * from './domains';
export * from './errors';
export * from './fallback';
diff --git a/src/utils/keys.test.ts b/src/utils/keys.test.ts
index c98a5abc..2835339f 100644
--- a/src/utils/keys.test.ts
+++ b/src/utils/keys.test.ts
@@ -13,6 +13,16 @@ describe('isUnsafeConfigKey', () => {
expect(isUnsafeConfigKey('X-Thread-ID')).toBe(false);
expect(isUnsafeConfigKey('price$')).toBe(false);
});
+
+ it('with dotted keys allowed, flags only $-prefixed keys and the prototype names', () => {
+ expect(isUnsafeConfigKey('us.anthropic.claude-sonnet-4-6', true)).toBe(false);
+ expect(isUnsafeConfigKey('gpt-4.1', true)).toBe(false);
+ expect(isUnsafeConfigKey('$where', true)).toBe(true);
+ expect(isUnsafeConfigKey('__proto__', true)).toBe(true);
+ expect(isUnsafeConfigKey('constructor', true)).toBe(true);
+ expect(isUnsafeConfigKey('prototype', true)).toBe(true);
+ expect(isUnsafeConfigKey('constructorName', true)).toBe(false);
+ });
});
describe('findUnsafeKeys', () => {
@@ -46,6 +56,23 @@ describe('findUnsafeKeys', () => {
).toEqual([{ path: 'endpoints.custom.11.headers', key: 'x.thread.id' }]);
});
+ it('with dotted keys allowed, finds only the keys the server refuses', () => {
+ expect(
+ findUnsafeKeys(
+ {
+ inferenceProfiles: { 'us.anthropic.x': 'arn' },
+ headers: [{ key: '$x', value: '1', valueType: 'string' }],
+ nested: { deeper: { $set: 1, 'a.b': 2 } },
+ },
+ 'endpoints.bedrock',
+ true,
+ ),
+ ).toEqual([
+ { path: 'endpoints.bedrock.headers', key: '$x' },
+ { path: 'endpoints.bedrock.nested.deeper', key: '$set' },
+ ]);
+ });
+
it('ignores dots in values and returns nothing for safe input', () => {
expect(findUnsafeKeys({ url: 'https://a.b.c', models: ['gpt-4.1'] }, 'x')).toEqual([]);
expect(findUnsafeKeys('a.b', 'x')).toEqual([]);
diff --git a/src/utils/keys.ts b/src/utils/keys.ts
index fd95fe02..bac33cb5 100644
--- a/src/utils/keys.ts
+++ b/src/utils/keys.ts
@@ -2,9 +2,17 @@ import type * as t from '@/types';
const UNSAFE_KEY_RE = /^\$|\./;
-/** Record keys the admin API cannot store: containing `.` or starting with `$`. */
-export function isUnsafeConfigKey(key: string): boolean {
- return UNSAFE_KEY_RE.test(key);
+/** Keys a server that keeps dotted keys (`dottedKeys`) refuses: `$`-prefixed and prototype names. */
+const RESERVED_KEY_RE = /^\$|^(?:__proto__|constructor|prototype)$/;
+
+/**
+ * Record keys the admin API cannot store. An older server silently drops a
+ * key containing `.` or starting with `$`; one that keeps dotted keys
+ * (`allowDots`, its `dottedKeys` feature) refuses only `$`-prefixed keys and
+ * the prototype names.
+ */
+export function isUnsafeConfigKey(key: string, allowDots = false): boolean {
+ return (allowDots ? RESERVED_KEY_RE : UNSAFE_KEY_RE).test(key);
}
function isKeyValuePairList(value: t.ConfigValue[]): value is t.KeyValuePair[] {
@@ -20,22 +28,27 @@ function isKeyValuePairList(value: t.ConfigValue[]): value is t.KeyValuePair[] {
/**
* Every object key (or key/value-pair key) under `value` that the admin API
- * would silently drop, with the dot-path of the object that holds it.
+ * would drop or refuse (see `isUnsafeConfigKey`), with the dot-path of the
+ * object that holds it.
*/
-export function findUnsafeKeys(value: t.ConfigValue, basePath: string): t.UnsafeConfigKey[] {
+export function findUnsafeKeys(
+ value: t.ConfigValue,
+ basePath: string,
+ allowDots = false,
+): t.UnsafeConfigKey[] {
if (value == null || typeof value !== 'object') return [];
if (Array.isArray(value)) {
if (isKeyValuePairList(value)) {
return value
- .filter((pair) => typeof pair.key === 'string' && isUnsafeConfigKey(pair.key))
+ .filter((pair) => typeof pair.key === 'string' && isUnsafeConfigKey(pair.key, allowDots))
.map((pair) => ({ path: basePath, key: pair.key }));
}
- return value.flatMap((item, index) => findUnsafeKeys(item, `${basePath}.${index}`));
+ return value.flatMap((item, index) => findUnsafeKeys(item, `${basePath}.${index}`, allowDots));
}
const found: t.UnsafeConfigKey[] = [];
for (const [key, child] of Object.entries(value)) {
- if (isUnsafeConfigKey(key)) found.push({ path: basePath, key });
- found.push(...findUnsafeKeys(child, basePath ? `${basePath}.${key}` : key));
+ if (isUnsafeConfigKey(key, allowDots)) found.push({ path: basePath, key });
+ found.push(...findUnsafeKeys(child, basePath ? `${basePath}.${key}` : key, allowDots));
}
return found;
}
diff --git a/src/utils/leaves.ts b/src/utils/leaves.ts
index 88000dc3..040fb5ea 100644
--- a/src/utils/leaves.ts
+++ b/src/utils/leaves.ts
@@ -1,29 +1,110 @@
import { configSchema } from 'librechat-data-provider';
import type * as t from '@/types';
-import { APP_SERVICE_KEY_ALIASES } from '@/constants';
-import { resolveSubSchema } from './schema';
+import { APP_SERVICE_KEY_ALIASES, BUNDLED_CONFIG_FEATURES } from '@/constants';
+import { isUnionSchema, resolveSubSchema, unwrapSchema } from './schema';
+import { hasDottedKey, isConfigRecord } from './format';
import { isUnsafeConfigKey } from './keys';
-import { isConfigRecord } from './format';
/** Top-level YAML keys that describe the file itself rather than a setting. */
const IMPORT_METADATA_KEYS = new Set(['version']);
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+/** Schema nodes whose keys are free names (where LibreChat keeps dotted keys), not setting names. */
+const FREE_KEY_TYPES: ReadonlySet = new Set(['ZodRecord', 'ZodAny', 'ZodUnknown']);
+
+/** Schema nodes whose value is a group of settings (an MCP server, a file config endpoint). */
+const GROUP_TYPES: ReadonlySet = new Set(['ZodObject', 'ZodRecord', 'ZodIntersection']);
+
+/** Type names of a schema node, one per union option (wrappers stripped, nested unions flattened). */
+function typeNamesOf(schema: t.ZodSchemaLike): string[] {
+ const node = unwrapSchema(schema);
+ if (!isUnionSchema(node)) return [node?._def?.typeName ?? ''];
+ return (node._def?.options ?? []).flatMap(typeNamesOf);
+}
+
+/**
+ * Whether the object at `path` names its keys freely (a record of headers,
+ * inference profiles, MIME types): the bundled schema says so (for a union,
+ * one of its options), or does not describe the path (a newer LibreChat's
+ * setting, which LibreChat judges). Top-level sections and fixed-shape
+ * objects name settings instead.
+ */
+function holdsFreeKeys(path: string): boolean {
+ if (!path) return false;
+ const schema = resolveSubSchema(configSchema as t.ZodSchemaLike, path.split('.'));
+ if (!schema) return true;
+ return typeNamesOf(schema).some((name) => FREE_KEY_TYPES.has(name));
+}
+
+/**
+ * Whether the free-key record `value` at `path` names groups of settings
+ * (MCP servers, file config endpoints) rather than plain values: the schema
+ * says its entries are objects, or one of them is. Such an entry is imported
+ * field by field, merged into the stored one, so a whole-record write (which
+ * replaces each entry) can't stand in for it.
+ */
+function holdsGroups(path: string, value: t.ConfigRecord): boolean {
+ if (Object.values(value).some(isConfigRecord)) return true;
+ const entry = resolveSubSchema(configSchema as t.ZodSchemaLike, [...path.split('.'), '*']);
+ if (!entry) return false;
+ const names = typeNamesOf(entry);
+ return names.length > 0 && names.every((name) => GROUP_TYPES.has(name));
+}
+
+/**
+ * Whether `key` of the object at `prefix` can be imported. A key the admin
+ * API can't store (`isUnsafeConfigKey`) is reported in `unsafe`; a prototype
+ * name it would store is dropped.
+ */
+function isImportableKey(
+ key: string,
+ prefix: string,
+ out: t.ImportLeaves,
+ allowDots: boolean,
+): boolean {
+ if (isUnsafeConfigKey(key, allowDots)) {
+ out.unsafe.push({ path: prefix, key });
+ return false;
+ }
+ return !DANGEROUS_KEYS.has(key);
+}
+
/**
* Leaves of `value` by dot-path, like `flattenObject`, except that a key the
- * admin API can't store (containing `.` or starting with `$`) is never split
- * into a nested path: it is reported in `unsafe` and its value is left out.
+ * admin API can't store (`isUnsafeConfigKey`) is never split into a nested
+ * path: it is reported in `unsafe` and its value is left out. With
+ * `allowDots` (the server's `dottedKeys`), a record of plain values that
+ * holds a dotted key is one leaf at its own path (a dot-path can't address
+ * the key), without its reserved keys (reported in `unsafe`, so the rest
+ * is still imported); a dotted name in a record of setting groups is
+ * reported in `dottedNames` (its fields are written one by one, by path),
+ * and a dotted key where the schema expects a setting name in `dotted`.
*/
-function collectLeaves(value: t.ConfigRecord, prefix: string, out: t.ImportLeaves): void {
+function collectLeaves(
+ value: t.ConfigRecord,
+ prefix: string,
+ out: t.ImportLeaves,
+ allowDots: boolean,
+): void {
+ const freeKeys = allowDots && hasDottedKey(value) && holdsFreeKeys(prefix);
+ if (freeKeys && !holdsGroups(prefix, value)) {
+ out.leaves[prefix] = Object.fromEntries(
+ Object.entries(value).filter(([key]) => isImportableKey(key, prefix, out, allowDots)),
+ );
+ return;
+ }
for (const [key, child] of Object.entries(value)) {
- if (DANGEROUS_KEYS.has(key)) continue;
- if (isUnsafeConfigKey(key)) {
- out.unsafe.push({ path: prefix, key });
+ if (!isImportableKey(key, prefix, out, allowDots)) continue;
+ if (key.includes('.')) {
+ (freeKeys ? out.dottedNames : out.dotted).push({ path: prefix, key });
continue;
}
const path = prefix ? `${prefix}.${key}` : key;
- if (isConfigRecord(child) && Object.keys(child).length > 0) collectLeaves(child, path, out);
- else out.leaves[path] = child;
+ if (isConfigRecord(child) && Object.keys(child).length > 0) {
+ collectLeaves(child, path, out, allowDots);
+ } else {
+ out.leaves[path] = child;
+ }
}
}
@@ -32,7 +113,10 @@ function collectLeaves(value: t.ConfigRecord, prefix: string, out: t.ImportLeave
* sections (`interfaceConfig`) are merged with their canonical section
* (`interface`) leaf by leaf, the canonical spelling winning on conflicts.
*/
-export function flattenImport(appConfig: t.ConfigRecord): t.ImportLeaves {
+export function flattenImport(
+ appConfig: t.ConfigRecord,
+ features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+): t.ImportLeaves {
const aliased: t.ConfigRecord = {};
const canonical: t.ConfigRecord = {};
for (const [key, value] of Object.entries(appConfig)) {
@@ -41,19 +125,24 @@ export function flattenImport(appConfig: t.ConfigRecord): t.ImportLeaves {
if (alias) aliased[alias] = value;
else canonical[key] = value;
}
- const fromAlias: t.ImportLeaves = { leaves: {}, unsafe: [] };
- const fromCanonical: t.ImportLeaves = { leaves: {}, unsafe: [] };
- collectLeaves(aliased, '', fromAlias);
- collectLeaves(canonical, '', fromCanonical);
+ const fromAlias: t.ImportLeaves = { leaves: {}, unsafe: [], dotted: [], dottedNames: [] };
+ const fromCanonical: t.ImportLeaves = { leaves: {}, unsafe: [], dotted: [], dottedNames: [] };
+ collectLeaves(aliased, '', fromAlias, features.dottedKeys);
+ collectLeaves(canonical, '', fromCanonical, features.dottedKeys);
return {
leaves: { ...fromAlias.leaves, ...fromCanonical.leaves },
unsafe: [...fromAlias.unsafe, ...fromCanonical.unsafe],
+ dotted: [...fromAlias.dotted, ...fromCanonical.dotted],
+ dottedNames: [...fromAlias.dottedNames, ...fromCanonical.dottedNames],
};
}
/** Leaf paths of an imported YAML the bundled config schema does not describe (aliases resolved). */
-export function findUnknownImportPaths(appConfig: t.ConfigRecord): string[] {
- return Object.keys(flattenImport(appConfig).leaves).filter(
+export function findUnknownImportPaths(
+ appConfig: t.ConfigRecord,
+ features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+): string[] {
+ return Object.keys(flattenImport(appConfig, features).leaves).filter(
(path) => resolveSubSchema(configSchema as t.ZodSchemaLike, path.split('.')) == null,
);
}
diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts
index af13c031..9a65937d 100644
--- a/src/utils/paths.test.ts
+++ b/src/utils/paths.test.ts
@@ -28,6 +28,16 @@ describe('collectLeafPaths', () => {
]);
});
+ it('keeps an object with a dotted key whole, since no path can name the key', () => {
+ expect(
+ collectLeafPaths({
+ endpoints: {
+ bedrock: { inferenceProfiles: { 'us.anthropic.x': 'arn:a', plain: 'arn:b' }, x: 1 },
+ },
+ }),
+ ).toEqual(['endpoints.bedrock.inferenceProfiles', 'endpoints.bedrock.x']);
+ });
+
it('returns the prefix itself for a primitive under a prefix', () => {
expect(collectLeafPaths(true, 'interface.schedules')).toEqual(['interface.schedules']);
expect(collectLeafPaths(undefined, 'x')).toEqual([]);
diff --git a/src/utils/paths.ts b/src/utils/paths.ts
index c6460881..9ceedede 100644
--- a/src/utils/paths.ts
+++ b/src/utils/paths.ts
@@ -1,5 +1,5 @@
import type * as t from '@/types';
-import { isConfigRecord } from './format';
+import { hasDottedKey, isConfigRecord } from './format';
const UNSAFE_SEGMENTS: ReadonlySet = new Set(['__proto__', 'constructor', 'prototype']);
@@ -9,7 +9,7 @@ function collectInto(value: t.ConfigRecord, prefix: string, out: string[]): void
const child = value[key];
if (child === undefined) continue;
const path = prefix ? `${prefix}.${key}` : key;
- if (isConfigRecord(child)) {
+ if (isConfigRecord(child) && !hasDottedKey(child)) {
collectInto(child, path, out);
} else {
out.push(path);
@@ -18,9 +18,10 @@ function collectInto(value: t.ConfigRecord, prefix: string, out: string[]): void
}
/**
- * Leaf paths a config tree sets: arrays, primitives and `null` are leaves, and
- * an empty object sets nothing (LibreChat leaves `{}` behind when the last
- * override under a section is removed, and it must not count as a setting).
+ * Leaf paths a config tree sets: arrays, primitives, `null` and objects with a
+ * dotted key (`hasDottedKey`) are leaves, and an empty object sets nothing
+ * (LibreChat leaves `{}` behind when the last override under a section is
+ * removed, and it must not count as a setting).
*/
export function collectLeafPaths(value: t.ConfigValue, prefix = ''): string[] {
if (!isConfigRecord(value)) return value === undefined || !prefix ? [] : [prefix];
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
index 358443f9..96d58f5a 100644
--- a/src/utils/validation.ts
+++ b/src/utils/validation.ts
@@ -124,8 +124,50 @@ const ISSUE_MESSAGES: Readonly> = {
'can only be configured in librechat.yaml for process-backed MCP servers (command, args, env, cwd, stderr, stdio)',
BLANK_VALUE: 'is blank: enter a value, or clear the field',
BLANK_KEY: 'has a blank key: name it, or remove the entry',
+ UNSAFE_KEY:
+ "has a key LibreChat can't store yet (\"{{key}}\" contains '.' or starts with '$'); set it in librechat.yaml",
+ RESERVED_KEY:
+ "has a key LibreChat can't store (\"{{key}}\" starts with '$' or is a reserved name); rename it",
+ invalid_value: 'is not a valid value',
+ wrong_type: 'has the wrong type of value',
+ invalid_option: 'is not one of the allowed values',
+ too_small_value: 'is too small',
+ too_big_value: 'is too large',
+ dotted_key: "has a key with a dot where LibreChat expects a setting's name",
+ unknown_section: "isn't a section LibreChat knows",
};
+/**
+ * Codes whose message needs parameters, worded without them: the admin API
+ * reports issue codes only (no minimum, expected type or options).
+ */
+const PARAMLESS_CODES: Readonly> = {
+ invalid_type: 'wrong_type',
+ invalid_literal: 'invalid_value',
+ invalid_enum_value: 'invalid_option',
+ too_small: 'too_small_value',
+ too_small_exclusive: 'too_small_value',
+ too_small_length: 'too_small_value',
+ too_small_items: 'too_small_value',
+ too_big: 'too_big_value',
+ too_big_exclusive: 'too_big_value',
+ too_big_length: 'too_big_value',
+ too_big_items: 'too_big_value',
+};
+
+const PLACEHOLDER_RE = /\{\{\w+\}\}/;
+
+/**
+ * The code to show for an issue reported without parameters: one whose
+ * message names them (`too_small`: "must be at least {{minimum}}") becomes
+ * its parameter-free wording ("is too small"); other codes stay.
+ */
+export function paramlessIssueCode(code: string): string {
+ const template = ISSUE_MESSAGES[code];
+ if (!template || !PLACEHOLDER_RE.test(template)) return code;
+ return PARAMLESS_CODES[code] ?? 'invalid_value';
+}
+
/** Fills `{{param}}` placeholders. */
export function interpolateIssue(
template: string,
diff --git a/src/utils/values.ts b/src/utils/values.ts
index 3618e845..59d9c5ed 100644
--- a/src/utils/values.ts
+++ b/src/utils/values.ts
@@ -50,8 +50,10 @@ const BLOCK_SHADOWED_KEYS: ReadonlySet = new Set([
'titlePromptTemplate',
]);
-/** Activity and reasoning-label settings where a value in `endpoints.all` wins per field. */
+/** Settings where a value in `endpoints.all` wins per field: title timing, stream rate, activity and reasoning labels. */
const FIELD_SHADOWED_KEYS: ReadonlySet = new Set([
+ 'titleTiming',
+ 'streamRate',
'activityLabel',
'activityModel',
'activityEndpoint',