diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx
index 8424c8b5..31d79c03 100644
--- a/src/components/configuration/ConfigPage.tsx
+++ b/src/components/configuration/ConfigPage.tsx
@@ -531,6 +531,7 @@ export function ConfigPage({
scope={editingScope}
onBackToBase={backToBase}
onError={notifyError}
+ canEditPriority={canManageConfig}
values={layers.scopeOverrideValues}
pendingResets={edits.pendingResets}
onRevert={
diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx
index ebed52d4..16acd8ec 100644
--- a/src/components/configuration/ConfigTabContent.tsx
+++ b/src/components/configuration/ConfigTabContent.tsx
@@ -27,7 +27,8 @@ import { ConfigSection } from './ConfigSection';
import { CodeField } from './fields/CodeField';
import { InfoBanner } from './InfoBanner';
import { ScopeGate } from './ScopeGate';
-import { useLocalize } from '@/hooks';
+import { useLocalize, useConfigContract } from '@/hooks';
+import { getWriteRules } from '@/constants';
/**
* Enter in an input of the tab form must never submit it (and so "click" its
@@ -149,8 +150,11 @@ export function ConfigTabContent({
return map;
}, [sectionCounts]);
+ const contract = useConfigContract();
const valuesData = useMemo(
() => ({
+ writeRules: getWriteRules(contract),
+ titleResolution: contract.rules.titleResolution,
configValues,
editedValues,
resolvedValues,
@@ -173,6 +177,7 @@ export function ConfigTabContent({
isEditingScope,
valueLayers,
visibilityEdits,
+ contract,
],
);
diff --git a/src/components/configuration/ScopeBar.tsx b/src/components/configuration/ScopeBar.tsx
index f7b4d75f..344511ee 100644
--- a/src/components/configuration/ScopeBar.tsx
+++ b/src/components/configuration/ScopeBar.tsx
@@ -4,9 +4,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import type * as t from '@/types';
import { formatScopeName, isSameScope, findPriorityTies, countScopeOverrides } from '@/utils';
import { availableScopesOptions, toggleScopeActiveFn } from '@/server';
+import { useLocalize, useConfigFeatures } from '@/hooks';
import { ScopeOverridesDialog } from './ScopeOverridesDialog';
+import { ScopePriorityEditor } from './ScopePriority';
import { getScopeTypeConfig } from '@/constants';
-import { useLocalize } from '@/hooks';
const TYPE_LABEL_KEYS: Readonly> = {
role: 'com_scope_type_role',
@@ -16,13 +17,17 @@ const TYPE_LABEL_KEYS: Readonly> = {
/**
* Persistent context bar shown under the tabs while a profile is open: which
- * profile is being edited, its priority (with ties), members, override count,
- * whether it is active, and the way back to Base (WS8: PR-1, PR-2).
+ * profile is being edited, its priority (with ties, and Change where the
+ * server has a priority route, D6), members, override count, whether it is
+ * active, and the way back to Base (WS8: PR-1, PR-2). Ties with profiles
+ * that change the same settings are a warning while LibreChat leaves their
+ * order undefined; with `deterministicPrecedence` the bar says which wins.
*/
export function ScopeBar({
scope,
onBackToBase,
onError,
+ canEditPriority = false,
values,
pendingResets,
onRevert,
@@ -35,6 +40,7 @@ export function ScopeBar({
}: t.ScopeBarProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
+ const { deterministicPrecedence, priorityRoute } = useConfigFeatures();
const [toggling, setToggling] = useState(false);
const [overridesOpen, setOverridesOpen] = useState(false);
const { data: scopes, isSuccess, isFetching } = useQuery(availableScopesOptions);
@@ -44,6 +50,8 @@ export function ScopeBar({
const isDeleted = isSuccess && !isFetching && !live;
const ties = useMemo(() => findPriorityTies(current, scopes ?? []), [current, scopes]);
const conflicts = ties.filter((tie) => tie.sharedPaths.length > 0);
+ const winsOver = conflicts.filter((tie) => tie.appliesAfter);
+ const losesTo = conflicts.filter((tie) => !tie.appliesAfter);
const config = getScopeTypeConfig(current.principalType);
const typeLabel = localize(TYPE_LABEL_KEYS[current.principalType] ?? config.labelKey);
const overrideCount = countScopeOverrides(current);
@@ -70,8 +78,8 @@ export function ScopeBar({
}
};
+ const priorityEditable = canEditPriority && priorityRoute && !isDeleted;
const details = [
- localize('com_scope_priority', { priority: current.priority }),
current.memberCount != null &&
localize('com_scope_member_count', { count: current.memberCount }),
overrideCount > 0
@@ -102,18 +110,47 @@ export function ScopeBar({
{localize('com_scope_bar_name', { type: typeLabel, name: formatScopeName(current) })}
-
- {details.map((item, i) => (
+ {priorityEditable ? (
+
+ ) : (
+ {localize('com_scope_priority', { priority: current.priority })}
+ )}
+ {details.map((item) => (
- {i > 0 && · }
+ ·
{item}
))}
-
- {conflicts.length > 0 && (
+
+ {deterministicPrecedence &&
+ [
+ { list: winsOver, key: 'com_scope_tie_order_wins' },
+ { list: losesTo, key: 'com_scope_tie_order_loses' },
+ ].map(
+ ({ list, key }) =>
+ list.length > 0 && (
+
+ {localize(key, {
+ names: tieNames(list),
+ count: new Set(list.flatMap((tie) => tie.sharedPaths)).size,
+ })}
+
+ ),
+ )}
+ {!deterministicPrecedence && conflicts.length > 0 && (
)}
- {conflicts.length === 0 && ties.length > 0 && (
+ {!deterministicPrecedence && conflicts.length === 0 && ties.length > 0 && (
{localize('com_scope_tie_note', { names: tieNames(ties) })}
diff --git a/src/components/configuration/ScopePriority.tsx b/src/components/configuration/ScopePriority.tsx
new file mode 100644
index 00000000..05300a95
--- /dev/null
+++ b/src/components/configuration/ScopePriority.tsx
@@ -0,0 +1,138 @@
+import { useId, useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { Button, NumberField } from '@clickhouse/click-ui';
+import type * as t from '@/types';
+import { MAX_SCOPE_PRIORITY } from '@/constants';
+import { setScopePriorityFn } from '@/server';
+import { useLocalize } from '@/hooks';
+import { errorText } from '@/utils';
+
+const PRIORITY_FORMAT = new Intl.NumberFormat();
+
+/** The problem with a typed priority, or null when it can be saved. */
+function priorityProblem(text: string, localize: t.LocalizeFn): string | null {
+ const value = Number(text);
+ if (text.trim() === '' || !Number.isInteger(value)) {
+ return localize('com_scope_priority_invalid');
+ }
+ if (value < 0 || value > MAX_SCOPE_PRIORITY) {
+ return localize('com_scope_priority_range', {
+ max: PRIORITY_FORMAT.format(MAX_SCOPE_PRIORITY),
+ });
+ }
+ return null;
+}
+
+/**
+ * A profile's priority with Change (D6, `priorityRoute`): an inline number
+ * field that sets only the priority, leaving the profile's settings and
+ * active state as they are. Enter saves, Escape cancels.
+ */
+export function ScopePriorityEditor({ scope, onError }: t.ScopePriorityEditorProps) {
+ const localize = useLocalize();
+ const queryClient = useQueryClient();
+ const inputId = useId();
+ const errorId = useId();
+ const [draft, setDraft] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [problem, setProblem] = useState(null);
+
+ const close = () => {
+ setDraft(null);
+ setProblem(null);
+ };
+
+ const save = async () => {
+ if (draft == null || saving) return;
+ const invalid = priorityProblem(draft, localize);
+ if (invalid) {
+ setProblem(invalid);
+ return;
+ }
+ const priority = Number(draft);
+ if (priority === scope.priority) {
+ close();
+ return;
+ }
+ setSaving(true);
+ try {
+ await setScopePriorityFn({
+ data: { principalType: scope.principalType, principalId: scope.principalId, priority },
+ });
+ await queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
+ close();
+ } catch (err) {
+ onError?.(errorText(err, localize('com_scope_priority_error'), localize));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (draft == null) {
+ return (
+
+ {localize('com_scope_priority', { priority: scope.priority })}
+ setDraft(String(scope.priority))}
+ aria-label={localize('com_scope_priority_change_label')}
+ >
+ {localize('com_scope_priority_change')}
+
+
+ );
+ }
+
+ return (
+
+
+ {
+ setDraft(next);
+ setProblem(null);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ void save();
+ }
+ if (event.key === 'Escape') close();
+ }}
+ />
+
+ void save()}
+ />
+
+ {problem && (
+
+ {problem}
+
+ )}
+
+ );
+}
diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx
index 7c609d2a..b28522f6 100644
--- a/src/components/configuration/ScopeSelector.tsx
+++ b/src/components/configuration/ScopeSelector.tsx
@@ -24,7 +24,7 @@ import {
isCurrentSelection,
countScopeOverrides,
} from '@/utils';
-import { useLocalize, useDebouncedFilter } from '@/hooks';
+import { useLocalize, useConfigFeatures, useDebouncedFilter } from '@/hooks';
import { DecorativeIcon } from '@/components/shared';
import { getScopeTypeConfig } from '@/constants';
@@ -58,6 +58,10 @@ export function ScopeSelector({
}: t.ScopeSelectorProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
+ const { deterministicPrecedence } = useConfigFeatures();
+ const precedenceKey = deterministicPrecedence
+ ? 'com_scope_precedence_ordered'
+ : 'com_scope_precedence';
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [creatingKey, setCreatingKey] = useState(null);
@@ -433,7 +437,7 @@ export function ScopeSelector({
{localize('com_scope_select')}
- {localize('com_scope_precedence')}
+ {localize(precedenceKey)}
- {localize('com_scope_precedence')}
+ {localize(precedenceKey)}
);
@@ -543,12 +547,14 @@ function ScopeRow({
toggling,
}: t.ScopeRowProps) {
const localize = useLocalize();
+ const { deterministicPrecedence } = useConfigFeatures();
const config = getScopeTypeConfig(scope.principalType);
const name = formatScopeName(scope);
const overrideCount = countScopeOverrides(scope);
const conflicts = ties.filter((tie) => tie.sharedPaths.length > 0);
+ /** With a fixed order, a tie decides which profile wins rather than leaving it undefined. */
const tieText =
- conflicts.length > 0
+ !deterministicPrecedence && conflicts.length > 0
? localize('com_scope_tie_short', {
names: conflicts.map((tie) => formatScopeName(tie.scope)).join(', '),
})
diff --git a/src/components/configuration/__tests__/ScopeBar.test.tsx b/src/components/configuration/__tests__/ScopeBar.test.tsx
index 2ed53c72..9983ee84 100644
--- a/src/components/configuration/__tests__/ScopeBar.test.tsx
+++ b/src/components/configuration/__tests__/ScopeBar.test.tsx
@@ -4,7 +4,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import type * as t from '@/types';
-import { availableScopesOptions, toggleScopeActiveFn } from '@/server';
+import { setScopePriorityFn, toggleScopeActiveFn, availableScopesOptions } from '@/server';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { createServerContract } from '@/test/fixtures';
import { ScopeActionsProvider } from '@/contexts/ScopeContext';
import { ScopeGate } from '../ScopeGate';
import { ScopeBar } from '../ScopeBar';
@@ -22,10 +24,13 @@ vi.mock('@/hooks', async () => {
await vi.importActual('@/hooks/config/scope');
const { useReturnFocus } =
await vi.importActual('@/hooks/dialogs');
+ const { useConfigFeatures } =
+ await vi.importActual('@/hooks/config/contract');
return {
useLocalize: () => localize,
useScopeActions,
useReturnFocus,
+ useConfigFeatures,
useExpandSignal: () => undefined,
};
});
@@ -33,6 +38,7 @@ vi.mock('@/hooks', async () => {
vi.mock('@/server', () => ({
availableScopesOptions: { queryKey: ['availableScopes'], queryFn: vi.fn() },
toggleScopeActiveFn: vi.fn(),
+ setScopePriorityFn: vi.fn(),
}));
const engineering: t.ConfigScope = {
@@ -112,6 +118,83 @@ describe('ScopeBar', () => {
});
});
+describe('ScopeBar on a server with deterministic precedence and a priority route', () => {
+ const contract = createServerContract({ deterministicPrecedence: true, priorityRoute: true });
+ const ordered = (children: ReactNode) =>
+ wrap(
+ {children} ,
+ );
+ const older = { ...engineering, _id: '6a00' };
+ const newer = { ...design, _id: '6a01' };
+
+ it('says which of two same-priority profiles wins instead of warning', async () => {
+ vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([older, newer]);
+ ordered( );
+ expect(
+ await screen.findByText(
+ 'com_scope_tie_order_loses(names=Design (com_scope_type_group),count=1)',
+ ),
+ ).toHaveAttribute('title', 'com_scope_tie_order_rule');
+ expect(screen.queryByText(/com_scope_tie_conflict/)).not.toBeInTheDocument();
+ });
+
+ it('says this profile wins over one that applies before it', async () => {
+ vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([older, newer]);
+ ordered( );
+ expect(
+ await screen.findByText(
+ 'com_scope_tie_order_wins(names=Engineering (com_scope_type_group),count=1)',
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it('offers Change only to an admin who may set priorities', async () => {
+ vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([engineering]);
+ const { unmount } = ordered( );
+ expect(screen.queryByRole('button', { name: 'com_scope_priority_change_label' })).toBeNull();
+ unmount();
+ wrap( );
+ expect(screen.queryByRole('button', { name: 'com_scope_priority_change_label' })).toBeNull();
+ });
+
+ it('changes the priority, refusing one that is not a whole number of 0 or more', async () => {
+ vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([engineering]);
+ vi.mocked(setScopePriorityFn).mockResolvedValue({ priority: 5 });
+ ordered( );
+ fireEvent.click(screen.getByRole('button', { name: 'com_scope_priority_change_label' }));
+ const input = screen.getByRole('spinbutton');
+ expect(input).toHaveValue(20);
+ fireEvent.change(input, { target: { value: '2.5' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(screen.getByRole('alert')).toHaveTextContent('com_scope_priority_invalid');
+ expect(setScopePriorityFn).not.toHaveBeenCalled();
+ fireEvent.change(input, { target: { value: '5' } });
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' }));
+ await waitFor(() =>
+ expect(setScopePriorityFn).toHaveBeenCalledWith({
+ data: { principalType: PrincipalType.GROUP, principalId: 'g-eng', priority: 5 },
+ }),
+ );
+ await waitFor(() => expect(screen.queryByRole('spinbutton')).toBeNull());
+ });
+
+ it('reports a refused change and keeps the field open', async () => {
+ vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([engineering]);
+ vi.mocked(setScopePriorityFn).mockRejectedValue(new Error('Config not found'));
+ const onError = vi.fn();
+ ordered(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'com_scope_priority_change_label' }));
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '30' } });
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'Enter' });
+ await waitFor(() => expect(onError).toHaveBeenCalledWith('Config not found'));
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument();
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'Escape' });
+ expect(screen.queryByRole('spinbutton')).toBeNull();
+ });
+});
+
describe('ScopeGate', () => {
it('explains why the section is not per profile and switches to Base', () => {
vi.mocked(availableScopesOptions.queryFn!).mockResolvedValue([engineering]);
diff --git a/src/components/configuration/__tests__/ScopeSelector.test.tsx b/src/components/configuration/__tests__/ScopeSelector.test.tsx
index 179a1725..921c9574 100644
--- a/src/components/configuration/__tests__/ScopeSelector.test.tsx
+++ b/src/components/configuration/__tests__/ScopeSelector.test.tsx
@@ -12,6 +12,9 @@ import {
allGroupsQueryOptions,
} from '@/server';
import { ScopeSelector } from '../ScopeSelector';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { createServerContract } from '@/test/fixtures';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
function localize(key: string, options?: Record): string {
if (!options) return key;
@@ -25,7 +28,9 @@ vi.mock('@/hooks', async () => {
const { useDebouncedFilter } = await vi.importActual(
'@/hooks/useDebouncedFilter',
);
- return { useLocalize: () => localize, useDebouncedFilter };
+ const { useConfigFeatures } =
+ await vi.importActual('@/hooks/config/contract');
+ return { useLocalize: () => localize, useDebouncedFilter, useConfigFeatures };
});
const scopes: t.ConfigScope[] = [
@@ -119,24 +124,31 @@ beforeEach(() => {
interface RenderOptions {
selection?: t.ScopeSelection;
isDirty?: boolean;
+ contract?: t.ConfigContract;
}
-function renderSelector({ selection = { type: 'BASE' }, isDirty = false }: RenderOptions = {}) {
+function renderSelector({
+ selection = { type: 'BASE' },
+ isDirty = false,
+ contract = BUNDLED_CONFIG_CONTRACT,
+}: RenderOptions = {}) {
const onSelect = vi.fn();
const onOpenChange = vi.fn();
const onError = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
-
+
+
+
,
);
return { onSelect, onOpenChange, onError };
@@ -173,6 +185,19 @@ describe('ScopeSelector list', () => {
}
});
+ it('describes the fixed order instead of warning when LibreChat orders ties', async () => {
+ scopes[2].isActive = true;
+ try {
+ renderSelector({ contract: createServerContract({ deterministicPrecedence: true }) });
+ const eng = await row(/^Engineering/);
+ expect(within(eng).queryByText('com_scope_tie_short(names=Design)')).toBeNull();
+ expect(screen.getAllByText('com_scope_precedence_ordered').length).toBeGreaterThan(0);
+ expect(screen.queryByText('com_scope_precedence')).toBeNull();
+ } finally {
+ scopes[2].isActive = false;
+ }
+ });
+
it('finds a user profile by name or email', async () => {
renderSelector();
await row(/^Bob Test/);
diff --git a/src/components/configuration/edits.ts b/src/components/configuration/edits.ts
index 81d57d4f..7f9eda27 100644
--- a/src/components/configuration/edits.ts
+++ b/src/components/configuration/edits.ts
@@ -133,7 +133,7 @@ export function buildSavePayload(
const skipped: t.SkippedChange[] = [];
for (const path of touched) {
const edited = editedValues[path];
- const reason = getWriteSkipReason(path, edited === undefined);
+ const reason = getWriteSkipReason(path, edited === undefined, context?.writeRules);
if (reason) {
skipped.push({ fieldPath: path, reason });
continue;
diff --git a/src/components/configuration/import.ts b/src/components/configuration/import.ts
index 37557f87..2f0d2199 100644
--- a/src/components/configuration/import.ts
+++ b/src/components/configuration/import.ts
@@ -11,7 +11,7 @@ import {
secretPathForPreviewPath,
stripSecretPreviewValues,
} from '@/utils';
-import { SKIP_REASON_KEYS, BUNDLED_CONFIG_FEATURES } from '@/constants';
+import { SKIP_REASON_KEYS, BUNDLED_WRITE_RULES, BUNDLED_CONFIG_FEATURES } from '@/constants';
import { findSchemaField } from './controls';
import { normalizeForSave } from './edits';
@@ -98,10 +98,12 @@ function skipReasonOf(
value: t.ConfigValue,
isScope: boolean,
features: t.ConfigFeatures,
+ rules: t.ConfigWriteRules,
): t.ImportSkipReason | null {
- const reason = getWriteSkipReason(path, false) ?? (isScope ? scopeGateSkipReason(path) : null);
+ const reason =
+ getWriteSkipReason(path, false, rules) ?? (isScope ? scopeGateSkipReason(path, rules) : null);
if (reason) return reason;
- if (findYamlManagedValues(path, value).length > 0) return 'yamlOnly';
+ if (findYamlManagedValues(path, value, rules).length > 0) return 'yamlOnly';
if (findUnsafeKeys(value, path, features.dottedKeys).length > 0) {
return unsafeKeyReason(features);
}
@@ -122,6 +124,7 @@ export function planImport(
schemaPathSet: ReadonlySet,
isScope = false,
features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
): t.ImportPlan {
const collected = collectImportEntries(appConfig, fields, schemaPathSet, features);
const entries: t.SaveEntry[] = [];
@@ -136,7 +139,7 @@ export function planImport(
processServers.add(server);
continue;
}
- const reason = skipReasonOf(entry.fieldPath, entry.value, isScope, features);
+ const reason = skipReasonOf(entry.fieldPath, entry.value, isScope, features, rules);
if (reason) {
skipped.push({ fieldPath: entry.fieldPath, reason });
continue;
@@ -152,7 +155,7 @@ export function planImport(
if (server && processServers.has(server)) continue;
const holderReason =
KEY_SKIP_REASONS.has(skip.reason) && skip.fieldPath
- ? getWriteSkipReason(skip.fieldPath, false)
+ ? getWriteSkipReason(skip.fieldPath, false, rules)
: null;
skipped.push(holderReason ? { fieldPath: skip.fieldPath, reason: holderReason } : skip);
}
diff --git a/src/components/configuration/renderer/FieldRenderer.tsx b/src/components/configuration/renderer/FieldRenderer.tsx
index 828c1e4b..38b2473b 100644
--- a/src/components/configuration/renderer/FieldRenderer.tsx
+++ b/src/components/configuration/renderer/FieldRenderer.tsx
@@ -12,7 +12,7 @@ import { SingleFieldRenderer } from './SingleField';
import { ManagedNotice } from '../ManagedNotice';
import { NestedGroup } from './NestedGroup';
import { labelForPath } from '../meta';
-import { useLocalize } from '@/hooks';
+import { useLocalize, useConfigWriteRules } from '@/hooks';
const NO_PATHS: ReadonlySet = new Set();
@@ -27,9 +27,13 @@ function asEnabled(value: t.ConfigValue): boolean | undefined {
}
/** The notice a field managed only in librechat.yaml shows at its root, or null below the root or for any other field. */
-function managedNoticeKind(path: string, parentPath: string): t.ManagedNoticeKind | null {
- if (!isYamlManagedPath(path) || isYamlManagedPath(parentPath)) return null;
- return isStartupOnlyPath(path) ? 'startup' : 'yaml';
+function managedNoticeKind(
+ path: string,
+ parentPath: string,
+ rules: t.ConfigWriteRules,
+): t.ManagedNoticeKind | null {
+ if (!isYamlManagedPath(path, rules) || isYamlManagedPath(parentPath, rules)) return null;
+ return isStartupOnlyPath(path, rules) ? 'startup' : 'yaml';
}
/**
@@ -40,12 +44,13 @@ function managedNoticeKind(path: string, parentPath: string): t.ManagedNoticeKin
function managedNoticeRuns(
groups: ReadonlyArray<{ type: string; path: string }>,
parentPath: string,
+ rules: t.ConfigWriteRules,
): Map {
const runs = new Map();
let start = -1;
let kind: t.ManagedNoticeKind | null = null;
groups.forEach((group, index) => {
- const next = group.type === 'nested' ? null : managedNoticeKind(group.path, parentPath);
+ const next = group.type === 'nested' ? null : managedNoticeKind(group.path, parentPath, rules);
if (next === 'startup' && next === kind) {
runs.get(start)?.push(group.path);
return;
@@ -87,6 +92,7 @@ export function FieldRenderer({
editSessionId,
}: t.FieldRendererProps) {
const localize = useLocalize();
+ const writeRules = useConfigWriteRules();
const values =
typeof parentValue === 'object' && parentValue !== null
? (parentValue as Record)
@@ -132,14 +138,14 @@ export function FieldRenderer({
}
}
- const noticeRuns = managedNoticeRuns(groups, parentPath);
+ const noticeRuns = managedNoticeRuns(groups, parentPath, writeRules);
/** The notice of managed fields (`settings`: how many it covers), offering to remove their stale overrides. */
const managedNotice = (paths: string[], settings = paths.length): ReactNode => {
const overridden = paths.filter((path) => hasPathOverlap(path, dbOverridePaths ?? NO_PATHS));
return (
0}
pendingRemoval={
@@ -157,8 +163,8 @@ export function FieldRenderer({
return (
{groups.map((group, index) => {
- const managed = isYamlManagedPath(group.path);
- const managedRoot = managed && !isYamlManagedPath(parentPath);
+ const managed = isYamlManagedPath(group.path, writeRules);
+ const managedRoot = managed && !isYamlManagedPath(parentPath, writeRules);
const noticeRun = noticeRuns.get(index);
const groupDisabled = disabled || managed;
if (group.type === 'nested') {
diff --git a/src/components/configuration/renderer/__tests__/controls.test.tsx b/src/components/configuration/renderer/__tests__/controls.test.tsx
index 24ddb493..c7bfd360 100644
--- a/src/components/configuration/renderer/__tests__/controls.test.tsx
+++ b/src/components/configuration/renderer/__tests__/controls.test.tsx
@@ -5,7 +5,8 @@ import type * as t from '@/types';
import { renderInlineField, collectionKeyOf } from '../InlineField';
import { deriveFieldDefault, layerValueBeneath } from '../defaults';
import { resolveControl, fieldOptions } from '../kinds';
-import { createField } from '@/test/fixtures';
+import { createField, createServerContract } from '@/test/fixtures';
+import { ConfigContractContext } from '@/contexts/ContractContext';
import { fitsControl } from '../FieldControl';
vi.mock('@/hooks/useLocalize', () => ({
@@ -566,6 +567,42 @@ describe('YAML-managed notice', () => {
expect(screen.queryByText('com_config_yaml_managed_field')).not.toBeInTheDocument();
});
+ it("marks the fields a server's rules keep to librechat.yaml, and frees the ones they don't", () => {
+ const contract = createServerContract(
+ {},
+ {
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ deploymentOnlyPaths: { 'endpoints.agents.eventDriven': 'startup' },
+ },
+ );
+ const text = (key: string) => createField({ key, path: `endpoints.agents.${key}` });
+ const fields = [text('eventDriven'), text('subagentActivity'), text('recursionLimit')];
+ render(
+
+
+
+ ,
+ );
+ expect(
+ screen.getAllByText(/^com_config_startup_only_field/).map((el) => el.textContent),
+ ).toEqual(['com_config_startup_only_field:{"count":1}']);
+ expect(screen.getAllByText('com_config_yaml_managed_field')).toHaveLength(1);
+ });
+
it('shows one notice above consecutive startup-only settings', () => {
const number = (key: string) =>
createField({ key, type: 'number', path: `mcpSettings.catalogRecovery.${key}` });
diff --git a/src/components/configuration/review/ChangeRow.tsx b/src/components/configuration/review/ChangeRow.tsx
index 3b7ae67c..8bf870f9 100644
--- a/src/components/configuration/review/ChangeRow.tsx
+++ b/src/components/configuration/review/ChangeRow.tsx
@@ -67,6 +67,7 @@ const WARNING_KEYS: Partial> = {
emptySpecs: 'com_config_review_warning_empty_specs',
shadowed: 'com_config_review_warning_shadowed',
shadowedBlock: 'com_config_review_warning_shadowed_block',
+ shadowedPair: 'com_config_review_warning_shadowed_pair',
};
function ActionButton({
diff --git a/src/components/configuration/sections/__tests__/coverage.test.tsx b/src/components/configuration/sections/__tests__/coverage.test.tsx
index b08a31d2..7f6b0793 100644
--- a/src/components/configuration/sections/__tests__/coverage.test.tsx
+++ b/src/components/configuration/sections/__tests__/coverage.test.tsx
@@ -1,5 +1,6 @@
import '@/locales/i18n';
import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
import type { HiddenFields } from '@/test/coverage';
import type * as t from '@/types';
import {
@@ -23,6 +24,7 @@ import {
import { READ_ONLY_CONFIG_SECTIONS, YAML_MANAGED_FIELDS } from '@/constants';
import { SECTION_META } from '@/components/configuration/configMeta';
import { renderTab, PAGE_TREE, RAW_TREE } from '@/test/page';
+import { createServerContract } from '@/test/fixtures';
import { setValueAtPath } from '@/utils';
vi.setConfig({ testTimeout: COVERAGE_TEST_TIMEOUT_MS });
@@ -57,6 +59,30 @@ function isReadOnly(path: string): boolean {
const HIDDEN: HiddenFields = {};
+/** A server that applies Base overrides before sign-in: the social sign-in buttons are edited in Base. */
+const PRE_AUTH_SERVER = createServerContract({ preAuthBaseOverrides: true });
+
+describe('social sign-in buttons, by the server', () => {
+ const yaml = { registration: { socialLogins: ['openid', 'google'] } };
+ const addOption = () => screen.queryByRole('combobox', { name: /Add sign-in option/ });
+
+ it('are read-only on a server without pre-auth Base overrides', () => {
+ renderTab({ tab: 'system', expandAll: true, yaml });
+ expect(controlAt('registration.socialLogins')).toHaveTextContent(/OpenID Connect.*Google/);
+ expect(addOption()).toBeNull();
+ });
+
+ it('are edited in Base, and saved, on a server with pre-auth Base overrides', () => {
+ const view = renderTab({ tab: 'system', expandAll: true, yaml, contract: PRE_AUTH_SERVER });
+ fireEvent.change(addOption()!, { target: { value: 'github' } });
+ const plan = view.plan();
+ expect(plan.sets).toEqual([
+ { fieldPath: 'registration.socialLogins', value: ['openid', 'google', 'github'] },
+ ]);
+ expect(plan.skipped).toEqual([]);
+ });
+});
+
describe.each(TABS)('%s tab', (tab) => {
const roots = rootsOf(tab);
const sample = sampleOf(roots);
@@ -81,6 +107,12 @@ describe.each(TABS)('%s tab', (tab) => {
expect(staleHidden(roots, HIDDEN)).toEqual([]);
});
+ it("renders every leaf of its sections under a server's write rules (pre-auth Base overrides)", () => {
+ renderIt({ yaml: sample, contract: PRE_AUTH_SERVER });
+ const shown = roots.flatMap((root) => renderedLeaves(root, sample));
+ expect(roots.flatMap((root) => missingLeaves(root, shown, HIDDEN))).toEqual([]);
+ });
+
it.each(editable.map((field) => [field.path, field] as const))(
'settings a newer schema adds under %s get a control, and an edit is saved at their path',
(_path, field) => {
diff --git a/src/components/configuration/sections/features/Credential.tsx b/src/components/configuration/sections/features/Credential.tsx
index 7d894546..fa38b0d1 100644
--- a/src/components/configuration/sections/features/Credential.tsx
+++ b/src/components/configuration/sections/features/Credential.tsx
@@ -3,6 +3,7 @@ import type * as t from '@/types';
import { SecretField, SecretInput } from '../../fields/SecretField';
import { FieldControl } from '../../renderer/FieldControl';
import { useLocalize, useConfigFeatures } from '@/hooks';
+import { isEncryptedCredential } from '@/constants';
import { DecorativeIcon } from '@/components/shared';
import { TextField } from '../../fields/TextField';
import { credentialState } from './state';
@@ -35,6 +36,7 @@ function statusLine(
storedKeysUsed: boolean,
localize: t.LocalizeFn,
): t.CredentialStatusLine | null {
+ const encrypted = isEncryptedCredential(credential, { webSearchStoredKeys: storedKeysUsed });
if (!storedKeysUsed && LITERAL_STATES.has(state.status)) {
return {
tone: 'warn',
@@ -44,7 +46,7 @@ function statusLine(
}
switch (state.status) {
case 'pending':
- return credential.encrypted
+ return encrypted
? { tone: 'muted', icon: 'lock', text: localize('com_config_features_cred_pending') }
: {
tone: 'warn',
@@ -66,7 +68,7 @@ function statusLine(
text: localize('com_config_features_cred_default', { env: state.env ?? credential.env }),
};
case 'set':
- return credential.secret && !credential.encrypted
+ return credential.secret && !encrypted
? {
tone: 'warn',
icon: 'warning',
diff --git a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
index 68845d4d..66801499 100644
--- a/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
+++ b/src/components/configuration/sections/features/__tests__/WebSearch.test.tsx
@@ -108,6 +108,20 @@ describe('WebSearchRenderer', () => {
expect(screen.queryByText(/environment variable references/)).not.toBeInTheDocument();
});
+ it('stores a Keenable key encrypted once the server keeps it in its secret registry', () => {
+ renderSection(WebSearchRenderer, {
+ section: 'webSearch',
+ yaml: { webSearch: { searchProvider: 'keenable' } },
+ features: { webSearchStoredKeys: true },
+ });
+ const key = screen.getByLabelText('Keenable API key');
+ fireEvent.change(key, { target: { value: 'kn-secret-1234567890' } });
+ fireEvent.blur(key);
+ expect(key).toHaveAttribute('type', 'password');
+ expect(screen.getByText('Will be stored encrypted when you save.')).toBeInTheDocument();
+ expect(screen.queryByText(/stored as typed|plain text/i)).not.toBeInTheDocument();
+ });
+
it('shows a stored key only as its masked preview, with Replace', () => {
renderSection(WebSearchRenderer, {
section: 'webSearch',
diff --git a/src/components/configuration/sections/features/__tests__/coverage.test.tsx b/src/components/configuration/sections/features/__tests__/coverage.test.tsx
index fbffaec4..4762cd4a 100644
--- a/src/components/configuration/sections/features/__tests__/coverage.test.tsx
+++ b/src/components/configuration/sections/features/__tests__/coverage.test.tsx
@@ -6,6 +6,7 @@ import type * as t from '@/types';
import {
COVERAGE_TEST_TIMEOUT_MS,
turnOn,
+ controlAt,
typeInto,
planWrites,
staleHidden,
@@ -25,6 +26,8 @@ import {
import { setValueAtPath, isInterfacePermissionPath } from '@/utils';
import { renderTab, PAGE_TREE, RAW_TREE } from '@/test/page';
import { labelForPath } from '@/components/configuration';
+import { createServerContract } from '@/test/fixtures';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
vi.setConfig({ testTimeout: COVERAGE_TEST_TIMEOUT_MS });
@@ -92,6 +95,23 @@ function roots(tree: t.SchemaField[]): t.SchemaField[] {
});
}
+/** A server that reports its write rules, as LibreChat lists them: the tool filters are deployment-only. */
+const SERVER_RULES = createServerContract(
+ {},
+ {
+ deploymentOnlyPaths: {
+ ...BUNDLED_CONFIG_CONTRACT.rules.deploymentOnlyPaths,
+ includedTools: 'operator',
+ filteredTools: 'operator',
+ },
+ },
+);
+
+const TOOL_FILTERS: CoverageFixture = {
+ name: 'tool filters librechat.yaml sets',
+ yaml: { filteredTools: ['dalle'], includedTools: ['calculator'] },
+};
+
describe('Features tab: every schema field is reachable', () => {
it('renders every leaf of its sections, or names why not', () => {
const shown = renderedAcross(roots(RAW_TREE), FIXTURES, (fixture) =>
@@ -106,6 +126,32 @@ describe('Features tab: every schema field is reachable', () => {
});
});
+describe("Features tab under a server's write rules", () => {
+ const renderWithRules = (fixture: CoverageFixture) =>
+ renderTab({ tab: 'features', tree: TREE, expandAll: true, contract: SERVER_RULES, ...fixture });
+
+ it('renders every leaf of its sections, or names why not', () => {
+ const shown = renderedAcross(roots(RAW_TREE), [...FIXTURES, TOOL_FILTERS], renderWithRules);
+ const missing = roots(RAW_TREE).flatMap((root) => missingLeaves(root, shown, HIDDEN));
+ expect(missing).toEqual([]);
+ });
+
+ it.each([
+ ['filteredTools', 'dalle'],
+ ['includedTools', 'calculator'],
+ ])('shows %s read-only, with the value librechat.yaml sets', (path, tool) => {
+ renderWithRules(TOOL_FILTERS);
+ const control = controlAt(path);
+ expect(control).toHaveTextContent(tool);
+ expect(control.querySelector('input')).toBeNull();
+ });
+
+ it('keeps the tool filters editable on a server that does not list them', () => {
+ renderTab({ tab: 'features', tree: TREE, expandAll: true, ...TOOL_FILTERS });
+ expect(controlAt('filteredTools').querySelector('input')).toHaveValue('dalle');
+ });
+});
+
/** Where a newer schema could add settings: each renderer, and the objects and variants inside. */
const NEW_FIELD_PARENTS: Array<[string, string, t.ConfigRecord]> = [
['interface', 'interface', {}],
diff --git a/src/components/configuration/sections/providers/Card.tsx b/src/components/configuration/sections/providers/Card.tsx
index fc1162ba..b4e9615d 100644
--- a/src/components/configuration/sections/providers/Card.tsx
+++ b/src/components/configuration/sections/providers/Card.tsx
@@ -79,6 +79,7 @@ export function ProviderCard({ field, parentPath, parentValue, ...rest }: t.Prov
configuredPaths,
editedValues,
root: values?.configValues ?? null,
+ titleResolution: values?.titleResolution,
}),
localize,
),
@@ -87,7 +88,11 @@ export function ProviderCard({ field, parentPath, parentValue, ...rest }: t.Prov
const description: ReactNode = isGlobal ? (
<>
- {localize('com_config_providers_global_desc')}
+ {localize(
+ values?.titleResolution === 'field'
+ ? 'com_config_providers_global_desc_field'
+ : 'com_config_providers_global_desc',
+ )}
{summary && (
{localize('com_config_providers_summary_set', { summary })}
@@ -109,7 +114,7 @@ export function ProviderCard({ field, parentPath, parentValue, ...rest }: t.Prov
const groupSet = group.fields.some((child) =>
isPathSet(`${path}.${child.key}`, configuredPaths, editedValues),
);
- if (key === 'titles' && isGlobal && !groupSet) {
+ if (key === 'titles' && isGlobal && !groupSet && values?.titleResolution !== 'field') {
return { summaryOverride: localize('com_config_providers_summary_global_titles') };
}
if (key === 'groups' && field.key === 'azureOpenAI') {
diff --git a/src/components/configuration/sections/providers/Group.tsx b/src/components/configuration/sections/providers/Group.tsx
index c93432a6..deae0d60 100644
--- a/src/components/configuration/sections/providers/Group.tsx
+++ b/src/components/configuration/sections/providers/Group.tsx
@@ -103,6 +103,7 @@ export function ProviderGroup({
configuredPaths,
editedValues,
root: values?.configValues ?? null,
+ titleResolution: values?.titleResolution,
}),
localize,
),
diff --git a/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx b/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx
index 2672d74d..d06d01af 100644
--- a/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx
+++ b/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx
@@ -54,12 +54,15 @@ const STATUS: t.EndpointStatusMap = {
agents: { name: 'agents', custom: false, userProvide: false },
};
-function renderProviders(overrides: Partial = {}) {
+function renderProviders(
+ overrides: Partial = {},
+ titleResolution?: t.ConfigRules['titleResolution'],
+) {
const client = new QueryClient();
client.setQueryData(['endpointStatus', 0], STATUS);
client.setQueryData(['endpointModels', 0], { openAI: ['gpt-5-mini', 'gpt-5'] });
const onChange = vi.fn();
- const values: t.ConfigValuesData = { configValues: CONFIG, editedValues: {} };
+ const values: t.ConfigValuesData = { configValues: CONFIG, editedValues: {}, titleResolution };
const wrapper = ({ children }: { children: ReactNode }) => (
{children}
@@ -133,6 +136,18 @@ describe('ProvidersRenderer', () => {
expect(within(titles).getByText('com_config_providers_summary_ignored')).toBeTruthy();
});
+ it('leaves title settings to each provider on a server that resolves them per field', () => {
+ renderProviders({}, 'field');
+ const summary = card('openAI').querySelector('.settings-help') as HTMLElement;
+ expect(summary.textContent).toContain('gpt-5-mini');
+ expect(summary.textContent).not.toContain('com_config_providers_summary_shadowed_ignored');
+ expect(card('all').textContent).toContain('com_config_providers_global_desc_field');
+ fireEvent.click(trigger('openAI'));
+ const titles = document.getElementById('section-endpoints.openAI-titles') as HTMLElement;
+ expect(within(titles).queryByText(/com_config_providers_global_titles_banner/)).toBeNull();
+ expect(within(titles).queryByText('com_config_providers_summary_ignored')).toBeNull();
+ });
+
it('opens Agent features first, with capability descriptions and paired limits', () => {
renderProviders();
fireEvent.click(trigger('agents'));
diff --git a/src/components/configuration/sections/providers/layout.ts b/src/components/configuration/sections/providers/layout.ts
index f057646f..a5b9eba8 100644
--- a/src/components/configuration/sections/providers/layout.ts
+++ b/src/components/configuration/sections/providers/layout.ts
@@ -21,6 +21,7 @@ const MAX_LIST_PREVIEW = 2;
const SHADOW_SUMMARY_KEYS = {
wins: 'com_config_providers_summary_shadowed',
ignored: 'com_config_providers_summary_shadowed_ignored',
+ paired: 'com_config_providers_summary_shadowed_pair',
} as const;
/** The purpose groups declared for a provider (the shared endpoint groups for an unknown one). */
@@ -189,15 +190,17 @@ export function createSummaryReader({
configuredPaths,
editedValues,
root,
+ titleResolution,
}: t.SummaryReaderInput): t.SummaryReader {
return {
isSet: (path) => isPathSet(path, configuredPaths, editedValues),
valueAt: (path) =>
getValue(path, getValueAtPath(providerValue, path.slice(providerPath.length + 1))),
shadowOf: (path) => {
- const shadow = resolveShadowedBy(path, root, editedValues);
+ const shadow = resolveShadowedBy(path, root, editedValues, titleResolution);
if (!shadow) return null;
- return shadow.value === undefined ? 'ignored' : 'wins';
+ if (shadow.value !== undefined) return 'wins';
+ return shadow.precedence === 'pair' ? 'paired' : 'ignored';
},
};
}
diff --git a/src/components/configuration/sections/specs/ChipPicker.tsx b/src/components/configuration/sections/specs/ChipPicker.tsx
index dba42268..28958001 100644
--- a/src/components/configuration/sections/specs/ChipPicker.tsx
+++ b/src/components/configuration/sections/specs/ChipPicker.tsx
@@ -13,7 +13,8 @@ const PLACEHOLDER = '\u0000add';
* A set of values picked from known choices: each as a chip with Remove, and
* a select of the choices not picked yet. Nothing can be typed, so a blank
* or unknown value can't be added; one already stored stays visible (marked
- * as not configured) until it is removed.
+ * as not configured) until it is removed. With `matchValue`, a value spelled
+ * another way than its choice (`Ollama` for `ollama`) is that choice.
*/
export function ChipPicker({
id,
@@ -24,6 +25,7 @@ export function ChipPicker({
disabled,
addLabel,
unknownLabel,
+ matchValue,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: t.ChipPickerProps) {
@@ -33,7 +35,8 @@ export function ChipPicker({
const labels = new Map(
groups.flatMap((group) => group.options.map((option) => [option.value, option.label])),
);
- const picked = new Set(values);
+ const choiceOf = (value: string): string => (matchValue ? matchValue(value) : value);
+ const picked = new Set(values.map(choiceOf));
const remaining = groups
.map((group) => ({
...group,
@@ -64,7 +67,7 @@ export function ChipPicker({
{values.length > 0 && (
{values.map((value) => {
- const label = labels.get(value);
+ const label = labels.get(choiceOf(value));
const known = label !== undefined;
return (
{
const served = normalizeEndpointName(name);
- return served !== name && options.knownEndpoints.has(served)
+ return !foldsNames && served !== name && options.knownEndpoints.has(served)
? localize('com_config_specs_added_served_as', { name, served })
: localize('com_config_specs_not_configured', { name });
}}
diff --git a/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx
index b76e4b93..3abedd2f 100644
--- a/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx
+++ b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx
@@ -11,6 +11,9 @@ import {
valuesDataFixture,
} from '@/test/layers';
import { CollectionEditsContext } from '@/contexts/CollectionEditsContext';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { createServerContract } from '@/test/fixtures';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext';
import { RowFilterContext } from '@/contexts/RowFilterContext';
import { ModelSpecsRenderer } from '../ModelSpecsRenderer';
@@ -57,22 +60,26 @@ function renderSpecs(
data,
collectionEdits = {},
showConfiguredOnly,
+ contract = BUNDLED_CONFIG_CONTRACT,
}: {
onChange?: t.FieldRendererProps['onChange'];
data?: t.ConfigValuesData;
collectionEdits?: t.CollectionEdits;
showConfiguredOnly?: boolean;
+ contract?: t.ConfigContract;
} = {},
) {
const values = data ?? valuesDataFixture(baseLayersFixture());
const wrap = (children: ReactNode) => (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
const ui = (
{
expect(onChange).toHaveBeenCalledWith('modelSpecs.addedEndpoints', ['openAI']);
});
+ it('matches an endpoint spelled as written once the server folds endpoint names', () => {
+ const values = valuesDataFixture(baseLayersFixture());
+ const data: t.ConfigValuesData = {
+ ...values,
+ configValues: {
+ ...values.configValues,
+ endpoints: { custom: [{ name: 'Ollama', baseURL: 'http://127.0.0.1:11434/v1' }] },
+ },
+ };
+ const optionsOf = () =>
+ [
+ ...(
+ document.getElementById('modelSpecs-addedEndpoints-add') as HTMLSelectElement
+ ).querySelectorAll('option'),
+ ].map((option) => [option.value, option.textContent]);
+ const before = renderSpecs({ list: SPECS, addedEndpoints: ['Ollama'] }, { data });
+ expect(
+ screen.getByText('com_config_specs_added_served_as {"name":"Ollama","served":"ollama"}'),
+ ).toBeInTheDocument();
+ expect(optionsOf()).toContainEqual(['ollama', 'Ollama (ollama)']);
+ before.unmount();
+ renderSpecs(
+ { list: SPECS, addedEndpoints: ['Ollama'] },
+ { data, contract: createServerContract({}, { titleResolution: 'field' }) },
+ );
+ expect(screen.queryByText(/com_config_specs_added_served_as/)).not.toBeInTheDocument();
+ const chip = screen.getByRole('button', {
+ name: 'com_config_specs_chip_remove {"name":"Ollama"}',
+ }).parentElement;
+ expect(chip).toHaveTextContent('Ollama');
+ expect(chip).toHaveAttribute('title', 'Ollama');
+ expect(optionsOf().map(([value]) => value)).not.toContain('ollama');
+ });
+
it('flags spec problems on the row and at the field', () => {
renderSpecs({
list: [...SPECS, { name: 'gpt', label: 'Copy', preset: { endpoint: 'Nope', model: 'm' } }],
diff --git a/src/components/configuration/sections/system/RegistrationRenderer.tsx b/src/components/configuration/sections/system/RegistrationRenderer.tsx
index 1bc18cb1..1ebd69a4 100644
--- a/src/components/configuration/sections/system/RegistrationRenderer.tsx
+++ b/src/components/configuration/sections/system/RegistrationRenderer.tsx
@@ -1,14 +1,15 @@
import { useContext, useState } from 'react';
import type * as t from '@/types';
import { getValueAtPath, isConfigValueEqual, stringItems } from '@/utils';
-import { useLocalize, useOwnedField, useYamlConfig } from '@/hooks';
+import { useLocalize, useOwnedField, useYamlConfig, useConfigFeatures } from '@/hooks';
import { ConfirmChoiceDialog } from '../../fields/ConfirmChoice';
import { RowFilterContext } from '@/contexts/RowFilterContext';
import { EmailDomainListField } from './EmailDomainListField';
import { UnknownSettings } from '../../UnknownSettings';
import { ReadOnlySection } from '../../ReadOnlySection';
import { FieldRenderer, toFieldId } from '../../renderer';
-import { SOCIAL_LOGIN_PROVIDERS } from '@/constants';
+import { SOCIAL_LOGIN_PROVIDERS, SOCIAL_LOGINS_PATH } from '@/constants';
+import { ChipPicker } from '../specs/ChipPicker';
import { isRowShown } from '../files/FieldRow';
import { SettingsRow } from '../../SettingsRow';
import { hasPathOverlap } from '../../counts';
@@ -18,15 +19,91 @@ import { YamlRows } from './Yaml';
const SECTION = 'registration';
const NO_PATHS: ReadonlySet = new Set();
-const SOCIAL = 'registration.socialLogins';
+const SOCIAL = SOCIAL_LOGINS_PATH;
const DOMAINS = 'registration.allowedDomains';
const CUSTOM_ROWS: ReadonlySet = new Set(['socialLogins', 'allowedDomains']);
/** Set up once at startup from librechat.yaml (`configureSocialLogins`): shown read-only. */
const STARTUP_KEYS: ReadonlySet = new Set(['oauthStateTtlMs', 'openidDiscovery']);
+/** A sign-in option's name ("Google"), or the stored value marked as not an option. */
+function providerLabel(provider: string, localize: t.LocalizeFn): string {
+ const labelKey = SOCIAL_LOGIN_PROVIDERS[provider];
+ return labelKey
+ ? localize(labelKey)
+ : localize('com_config_registration_provider_unknown', { name: provider });
+}
+
+/**
+ * Social sign-in buttons on a server that applies Base overrides before
+ * sign-in (`preAuthBaseOverrides`): picked from the sign-in options as chips,
+ * in the order the sign-in page shows them. The list beneath is librechat.yaml's
+ * (else LibreChat's default list); choosing it again removes the override.
+ */
+function EditableSocialLoginsRow({ fieldProps }: { fieldProps: t.FieldRendererProps }) {
+ const localize = useLocalize();
+ const rowFilter = useContext(RowFilterContext);
+ const social = useOwnedField(SOCIAL, fieldProps);
+ if (!isRowShown(rowFilter, SOCIAL, fieldProps)) return null;
+ const id = toFieldId(SOCIAL);
+ const providers = stringItems(social.state.value ?? fieldProps.schemaDefaults?.[SOCIAL]);
+ const lower = stringItems(social.fallback ?? fieldProps.schemaDefaults?.[SOCIAL]);
+ const change = (next: string[]) => {
+ if (isConfigValueEqual(next, lower) && social.own !== undefined) {
+ social.set(undefined);
+ return;
+ }
+ social.set(next);
+ };
+ const label = labelForPath(SOCIAL, localize);
+ return (
+
+
+ ({
+ value: provider,
+ label: providerLabel(provider, localize),
+ })),
+ },
+ ]}
+ disabled={fieldProps.disabled}
+ onChange={change}
+ addLabel={localize('com_config_registration_social_add')}
+ unknownLabel={(provider) => providerLabel(provider, localize)}
+ aria-label={label}
+ />
+ {providers.length === 0 && !fieldProps.disabled && (
+
+ {localize('com_config_registration_social_none')}
+
+ )}
+
+
+ );
+}
+
/**
- * Social sign-in buttons (FX-9, FX-10): the sign-in page reads them from
- * librechat.yaml only (else LibreChat's default list), so they are shown
+ * Social sign-in buttons (FX-9, FX-10) on a server that reads them before
+ * sign-in from librechat.yaml only (else LibreChat's default list): shown
* read-only by provider name from there, never from an override (in a
* profile, Base's), with a way to remove an override saved before (it never
* took effect).
@@ -56,16 +133,11 @@ function SocialLoginsRow({ fieldProps }: { fieldProps: t.FieldRendererProps }) {
{providers.length > 0 ? (
- {providers.map((provider) => {
- const labelKey = SOCIAL_LOGIN_PROVIDERS[provider];
- return (
-
- {labelKey
- ? localize(labelKey)
- : localize('com_config_registration_provider_unknown', { name: provider })}
-
- );
- })}
+ {providers.map((provider) => (
+
+ {providerLabel(provider, localize)}
+
+ ))}
) : (
@@ -101,10 +173,13 @@ function SocialLoginsRow({ fieldProps }: { fieldProps: t.FieldRendererProps }) {
/**
* Allowed sign-up domains as validated chips (FX-9). An empty list lets
- * anyone sign up, so emptying a restricted list asks first.
+ * anyone sign up, so emptying a restricted list asks first. Before
+ * `preAuthBaseOverrides`, social and SSO sign-ins and password resets check
+ * librechat.yaml's list instead; after it, they check this one.
*/
function AllowedDomainsRow({ fieldProps }: { fieldProps: t.FieldRendererProps }) {
const localize = useLocalize();
+ const { preAuthBaseOverrides } = useConfigFeatures();
const rowFilter = useContext(RowFilterContext);
const yaml = useYamlConfig();
const field = useOwnedField(DOMAINS, fieldProps);
@@ -177,7 +252,12 @@ function AllowedDomainsRow({ fieldProps }: { fieldProps: t.FieldRendererProps })
aria-label={label}
aria-describedby={fieldProps.disabled ? undefined : `${id}-status`}
/>
- {differsFromYaml && (
+ {preAuthBaseOverrides && (
+
+ {localize('com_config_registration_domains_all_note')}
+
+ )}
+ {!preAuthBaseOverrides && differsFromYaml && (
{localize('com_config_registration_domains_yaml_note')}
@@ -250,12 +330,14 @@ function StartupSettings({
}
/**
- * Registration (FX-9, FX-10): social sign-in providers (read only from
+ * Registration (FX-9, FX-10): social sign-in providers (editable in Base once
+ * LibreChat applies Base overrides before sign-in, else read only from
* librechat.yaml), allowed sign-up domains as chips, any other setting as a
* generic row, and the startup-only sign-in settings read-only.
*/
export function RegistrationRenderer(props: t.FieldRendererProps) {
const rowFilter = useContext(RowFilterContext);
+ const { preAuthBaseOverrides } = useConfigFeatures();
const keys = new Set(props.fields.map((field) => field.key));
const startup = props.fields.filter((field) => STARTUP_KEYS.has(field.key));
const others = props.fields.filter(
@@ -263,7 +345,12 @@ export function RegistrationRenderer(props: t.FieldRendererProps) {
);
return (
<>
- {keys.has('socialLogins') && }
+ {keys.has('socialLogins') &&
+ (preAuthBaseOverrides ? (
+
+ ) : (
+
+ ))}
{keys.has('allowedDomains') && }
{others.length > 0 && }
diff --git a/src/components/configuration/sections/system/__tests__/RegistrationRenderer.test.tsx b/src/components/configuration/sections/system/__tests__/RegistrationRenderer.test.tsx
index 8f149f79..e5c769e2 100644
--- a/src/components/configuration/sections/system/__tests__/RegistrationRenderer.test.tsx
+++ b/src/components/configuration/sections/system/__tests__/RegistrationRenderer.test.tsx
@@ -1,6 +1,7 @@
import '@/locales/i18n';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, within } from '@testing-library/react';
+import { createServerContract } from '@/test/fixtures';
import { renderSections } from '@/test/sections';
const YAML = {
@@ -25,6 +26,45 @@ describe('RegistrationRenderer', () => {
expect(screen.queryByRole('button', { name: /Remove openid/i })).not.toBeInTheDocument();
});
+ it('edits the providers in Base once Base overrides apply before sign-in', () => {
+ const onChange = vi.fn();
+ renderSections({
+ sections: ['registration'],
+ yaml: YAML,
+ onChange,
+ contract: createServerContract({ preAuthBaseOverrides: true }),
+ });
+ expect(screen.queryByText(/reads this list from librechat.yaml only/)).toBeNull();
+ fireEvent.change(screen.getByRole('combobox', { name: /Add sign-in option/ }), {
+ target: { value: 'github' },
+ });
+ expect(onChange).toHaveBeenLastCalledWith('registration.socialLogins', [
+ 'openid',
+ 'google',
+ 'github',
+ ]);
+ fireEvent.click(screen.getByRole('button', { name: 'Remove Google' }));
+ expect(onChange).toHaveBeenLastCalledWith('registration.socialLogins', ['openid', 'github']);
+ expect(
+ screen.getByText('Social, SSO and LDAP sign-ins and password resets check this list too.'),
+ ).toBeInTheDocument();
+ });
+
+ it('removes the Base list when it matches librechat.yaml again', () => {
+ const onChange = vi.fn();
+ renderSections({
+ sections: ['registration'],
+ yaml: YAML,
+ overrides: { registration: { socialLogins: ['openid'] } },
+ onChange,
+ contract: createServerContract({ preAuthBaseOverrides: true }),
+ });
+ fireEvent.change(screen.getByRole('combobox', { name: /Add sign-in option/ }), {
+ target: { value: 'google' },
+ });
+ expect(onChange).toHaveBeenLastCalledWith('registration.socialLogins', undefined);
+ });
+
it('in a profile, lists the librechat.yaml providers, not a stale Base override', () => {
renderSections({
sections: ['registration'],
diff --git a/src/components/configuration/values/ProvenanceChip.tsx b/src/components/configuration/values/ProvenanceChip.tsx
index f2882189..a1353f4b 100644
--- a/src/components/configuration/values/ProvenanceChip.tsx
+++ b/src/components/configuration/values/ProvenanceChip.tsx
@@ -53,7 +53,12 @@ export function shadowedSourceLabel(
function shadowReason(shadowedBy: t.ShadowedBy, source: string, localize: t.LocalizeFn): string {
const field = labelForPath(shadowedBy.path, localize);
if (shadowedBy.value === undefined) {
- return localize('com_config_providers_shadow_block_unset', { field, source });
+ return localize(
+ shadowedBy.precedence === 'pair'
+ ? 'com_config_providers_shadow_pair'
+ : 'com_config_providers_shadow_block_unset',
+ { field, source },
+ );
}
const value = formatConfigValue(shadowedBy.value, localize, shadowedBy.path);
return localize(
diff --git a/src/components/configuration/values/values.test.tsx b/src/components/configuration/values/values.test.tsx
index ad584bf0..6bec4843 100644
--- a/src/components/configuration/values/values.test.tsx
+++ b/src/components/configuration/values/values.test.tsx
@@ -4,12 +4,14 @@ import { render, screen, renderHook, fireEvent } from '@testing-library/react';
import type { ReactNode } from 'react';
import type * as t from '@/types';
import { baseLayersFixture, scopeLayersFixture, valuesDataFixture } from '@/test/layers';
+import { ProvenanceChip, FieldProvenance, ShadowBadge } from './ProvenanceChip';
import { formatConfigValue, getRevertLabels, sourceLabel } from './labels';
import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext';
-import { ProvenanceChip, FieldProvenance } from './ProvenanceChip';
+import { GlobalRevealContext } from '@/contexts/GlobalRevealContext';
+import { deriveFieldState, resolveShadowedBy } from '@/utils';
import { useFieldState, useRevertLabels } from '@/hooks';
import { TabCountBadge } from './TabCountBadge';
-import { deriveFieldState } from '@/utils';
+import { labelForPath } from '../meta';
vi.mock('@/hooks/useLocalize', () => ({
useLocalize: () => (key: string, options?: Record) =>
@@ -113,6 +115,25 @@ describe('ProvenanceChip', () => {
expect(reason).toBeVisible();
});
+ it('points a title model Global pairs away to the title endpoint Global sets', () => {
+ const tree = {
+ endpoints: { all: { titleEndpoint: 'anthropic' }, openAI: { titleModel: 'x' } },
+ };
+ const shadowedBy = resolveShadowedBy('endpoints.openAI.titleModel', tree, {}, 'field')!;
+ const reveal = vi.fn();
+ const { container } = render(
+
+
+ ,
+ );
+ const field = labelForPath('endpoints.all.titleEndpoint', localize);
+ expect(container.querySelector('.state-chip-reason')?.textContent).toBe(
+ `com_config_providers_shadow_pair${JSON.stringify({ field, source: 'librechat.yaml' })}`,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /com_config_providers_shadow_go/ }));
+ expect(reveal).toHaveBeenCalledWith('endpoints.all.titleEndpoint');
+ });
+
it('renders nothing for a plain default when asked to', () => {
const state = deriveFieldState(
'balance.autoRefillEnabled',
diff --git a/src/constants/collections.ts b/src/constants/collections.ts
index 59afbbb9..e8c40606 100644
--- a/src/constants/collections.ts
+++ b/src/constants/collections.ts
@@ -27,3 +27,17 @@ export const KEYED_COLLECTIONS: ReadonlyMap = new Map
] satisfies t.KeyedCollection[]
).map((collection): [string, t.KeyedCollection] => [collection.path, collection]),
);
+
+/**
+ * Records of named entries that each hold a credential beside the destination
+ * it is sent to, where LibreChat drops a credential whose destination changed
+ * (`secretRebinding`): the record it reports an entry in → the one the panel
+ * edits it in (Azure groups are reported in the `groupMap` that `groups`
+ * resolves to). Elsewhere a credential is bound to the object holding it
+ * (`ocr.apiKey` to `ocr.baseURL`).
+ */
+export const CREDENTIAL_ENTRY_RECORDS: ReadonlyMap = new Map([
+ ['endpoints.custom', 'endpoints.custom'],
+ ['mcpServers', 'mcpServers'],
+ ['endpoints.azureOpenAI.groupMap', 'endpoints.azureOpenAI.groups'],
+]);
diff --git a/src/constants/config.ts b/src/constants/config.ts
index 8daf6ec7..1b1c62d1 100644
--- a/src/constants/config.ts
+++ b/src/constants/config.ts
@@ -28,11 +28,55 @@ export const TERMS_NOT_ACCEPTED_ISSUE = 'TERMS_NOT_ACCEPTED';
*/
const INTERIM_BASE_ONLY_SECTIONS = ['mcpAppSandbox'];
+/**
+ * Sections LibreChat reads from librechat.yaml before sign-in (Turnstile),
+ * read-only on every server: one that reports its rules lists them as
+ * base-only, an older one reads them from the raw file.
+ */
+const PRE_AUTH_YAML_SECTIONS: readonly string[] = ['turnstile'];
+
+/**
+ * Sections and fields only librechat.yaml sets, as the panel knew them before
+ * servers reported `rules.deploymentOnlyPaths`: sections read at startup or
+ * from the raw librechat.yaml, and settings read once at startup.
+ */
+export const BUNDLED_DEPLOYMENT_ONLY_PATHS: Readonly> = {
+ cloudfront: 'operator',
+ rateLimits: 'operator',
+ /** Read as `req.config.config.permissions`: the raw librechat.yaml, never the merged overrides. */
+ permissions: 'operator',
+ /** The docs gate reads `getAppConfig({ baseOnly: true }).config.openapi`. */
+ openapi: 'operator',
+ /** Only librechat.yaml's own loader knows it; AppService never carries it into the app config. */
+ cache: 'unused',
+ /** Social sign-in and OpenID discovery are set up once at startup from librechat.yaml. */
+ 'registration.oauthStateTtlMs': 'startup',
+ 'registration.openidDiscovery': 'startup',
+ /**
+ * Read once at startup, from librechat.yaml alone (`getAppConfig({ baseOnly:
+ * true })` while the server boots): 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.
+ */
+ 'endpoints.agents.eventDriven': 'startup',
+ 'endpoints.agents.subagentActivity': 'startup',
+ 'endpoints.agents.toolApproval.hooks': 'startup',
+ 'endpoints.agents.backgroundTasks.shutdownInterruptGraceMs': 'startup',
+ 'endpoints.agents.backgroundTasks.completionResultBatchSize': 'startup',
+ 'endpoints.agents.backgroundTasks.completionReceiptBatching': 'startup',
+ 'mcpSettings.catalogRecovery.maxStateEntries': 'startup',
+ 'mcpSettings.catalogRecovery.maxDetachedDiscoveries': 'startup',
+ 'mcpSettings.catalogRecovery.authorizationFenceRetryIntervalMs': 'startup',
+ 'mcpSettings.catalogRecovery.authorizationFenceRetryBatchSize': 'startup',
+};
+
/**
* Top-level sections the panel must never write, with the reason shown in the
- * UI. Base-only sections are stripped by the admin API for every principal;
- * the YAML-only ones are read from the raw librechat.yaml at startup or before
- * login, so overrides have no effect.
+ * UI, on a server that does not report its rules. Base-only sections are
+ * stripped by the admin API for every principal; the YAML-only ones are read
+ * from the raw librechat.yaml at startup or before login, so overrides have
+ * no effect. A server that reports its rules decides them (`getWriteRules`).
*/
export const READ_ONLY_CONFIG_SECTIONS: ReadonlyMap = new Map<
string,
@@ -41,15 +85,10 @@ export const READ_ONLY_CONFIG_SECTIONS: ReadonlyMap =
...[...BASE_ONLY_CONFIG_SECTIONS, ...INTERIM_BASE_ONLY_SECTIONS].map(
(section): [string, t.ReadOnlyReason] => [section, 'baseOnly'],
),
- ['cloudfront', 'yamlOnly'],
- ['rateLimits', 'yamlOnly'],
- ['turnstile', 'yamlOnly'],
- /** Read as `req.config.config.permissions`: the raw librechat.yaml, never the merged overrides. */
- ['permissions', 'yamlOnly'],
- /** The docs gate reads `getAppConfig({ baseOnly: true }).config.openapi`. */
- ['openapi', 'yamlOnly'],
- /** Only librechat.yaml's own loader knows it; AppService never carries it into the app config. */
- ['cache', 'yamlOnly'],
+ ...Object.keys(BUNDLED_DEPLOYMENT_ONLY_PATHS)
+ .filter((path) => !path.includes('.'))
+ .map((section): [string, t.ReadOnlyReason] => [section, 'yamlOnly']),
+ ...PRE_AUTH_YAML_SECTIONS.map((section): [string, t.ReadOnlyReason] => [section, 'yamlOnly']),
]);
/**
@@ -67,43 +106,81 @@ 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',
-]);
+/** Social sign-in buttons: read before sign-in, from librechat.yaml until `preAuthBaseOverrides`. */
+export const SOCIAL_LOGINS_PATH = 'registration.socialLogins';
/**
- * Fields LibreChat only processes from librechat.yaml (overrides are merged
- * 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.
+ * 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.
*/
-export const YAML_MANAGED_FIELDS: ReadonlySet = new Set([
+const YAML_PROCESSED_FIELDS: readonly string[] = [
'endpoints.azureOpenAI.groups',
'endpoints.anthropic.vertex',
- /** The sign-in page reads it before login, from librechat.yaml only (unauthenticated `/api/config`). */
- 'registration.socialLogins',
- /** Social sign-in and OpenID discovery are set up once at startup from librechat.yaml. */
- 'registration.oauthStateTtlMs',
- 'registration.openidDiscovery',
- ...STARTUP_ONLY_FIELDS,
-]);
+];
+
+/**
+ * The fields of `rules`' deployment-only paths inside writable sections, and
+ * which of them are read only at startup.
+ */
+function deploymentOnlyFields(paths: Readonly>): {
+ fields: string[];
+ startup: string[];
+} {
+ const nested = Object.entries(paths).filter(([path]) => path.includes('.'));
+ return {
+ fields: nested.map(([path]) => path),
+ startup: nested.filter(([, reason]) => reason === 'startup').map(([path]) => path),
+ };
+}
+
+/**
+ * Where the panel may write on a server with these rules and features:
+ * base-only sections, deployment-only sections and fields, base-only fields,
+ * the fields LibreChat only processes from librechat.yaml, and, until Base
+ * overrides apply before sign-in (`preAuthBaseOverrides`), the social
+ * sign-in buttons.
+ */
+export function resolveWriteRules(
+ rules: Pick,
+ preAuthBaseOverrides: boolean,
+): t.ConfigWriteRules {
+ const readOnlySections = new Map();
+ for (const section of rules.baseOnlySections) readOnlySections.set(section, 'baseOnly');
+ for (const path of Object.keys(rules.deploymentOnlyPaths)) {
+ if (!path.includes('.')) readOnlySections.set(path, 'yamlOnly');
+ }
+ for (const section of PRE_AUTH_YAML_SECTIONS) readOnlySections.set(section, 'yamlOnly');
+ const { fields, startup } = deploymentOnlyFields(rules.deploymentOnlyPaths);
+ return {
+ readOnlySections,
+ yamlManagedFields: new Set([
+ ...YAML_PROCESSED_FIELDS,
+ ...(preAuthBaseOverrides ? [] : [SOCIAL_LOGINS_PATH]),
+ ...fields,
+ ...rules.baseOnlyPaths,
+ ]),
+ startupOnlyFields: new Set(startup),
+ };
+}
+
+/** The write rules of a server that reports none: every section and field as before servers did. */
+export const BUNDLED_WRITE_RULES: t.ConfigWriteRules = resolveWriteRules(
+ {
+ baseOnlySections: [...READ_ONLY_CONFIG_SECTIONS]
+ .filter(([, reason]) => reason === 'baseOnly')
+ .map(([section]) => section),
+ baseOnlyPaths: [],
+ deploymentOnlyPaths: BUNDLED_DEPLOYMENT_ONLY_PATHS,
+ },
+ false,
+);
+
+/** Fields LibreChat reads once at startup, from librechat.yaml alone, as the panel knew them. */
+export const STARTUP_ONLY_FIELDS: ReadonlySet = BUNDLED_WRITE_RULES.startupOnlyFields;
+
+/** Fields only librechat.yaml sets, as the panel knew them before servers reported their rules. */
+export const YAML_MANAGED_FIELDS: ReadonlySet = BUNDLED_WRITE_RULES.yamlManagedFields;
/** Config keys for values that must never be displayed in plain text. */
export const SECRET_CONFIG_KEYS: ReadonlySet = new Set([
@@ -128,6 +205,23 @@ export const SKIP_REASON_KEYS: Readonly> = {
baseScope: 'com_scope_skip_base_scope',
inheritedValue: 'com_scope_skip_inherited_value',
inheritedMerged: 'com_scope_skip_inherited_merged',
+ processServer: 'com_config_skip_process_server',
+ secretRebinding: 'com_config_skip_secret_rebinding',
+};
+
+/**
+ * The panel's skip reason for each reason the admin API reports a write did
+ * not store (`skipped`, with `writeReport`) or stores without effect
+ * (`ignored`). A reason a newer server adds reads as not stored or not applied.
+ */
+export const REPORTED_SKIP_REASONS: Readonly> = {
+ base_only_section: 'baseOnly',
+ base_only_path: 'baseOnly',
+ deployment_only_path: 'yamlOnly',
+ dedicated_section: 'dedicated',
+ interface_permission: 'permission',
+ process_mcp_server: 'processServer',
+ secret_rebinding: 'secretRebinding',
};
/** Why a lower-layer key comes back after a write: LibreChat merges it in, so it is reported but not lost. */
@@ -143,8 +237,11 @@ export function getConfigSection(path: string): string {
}
/** The top-level section a dot-path belongs to, when that section is read-only. */
-export function getReadOnlyReason(path: string): t.ReadOnlyReason | undefined {
- return READ_ONLY_CONFIG_SECTIONS.get(getConfigSection(path));
+export function getReadOnlyReason(
+ path: string,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): t.ReadOnlyReason | undefined {
+ return rules.readOnlySections.get(getConfigSection(path));
}
function isAtOrUnderAny(path: string, fields: ReadonlySet): boolean {
@@ -155,11 +252,17 @@ function isAtOrUnderAny(path: string, fields: ReadonlySet): boolean {
}
/** 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);
+export function isYamlManagedPath(
+ path: string,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): boolean {
+ return isAtOrUnderAny(path, rules.yamlManagedFields);
}
-/** 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);
+/** Whether `path` is, or is inside, a setting LibreChat reads only at startup. */
+export function isStartupOnlyPath(
+ path: string,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): boolean {
+ return isAtOrUnderAny(path, rules.startupOnlyFields);
}
diff --git a/src/constants/contract.ts b/src/constants/contract.ts
index c0a4214d..6016fe0c 100644
--- a/src/constants/contract.ts
+++ b/src/constants/contract.ts
@@ -6,7 +6,13 @@ import {
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 {
+ resolveWriteRules,
+ BUNDLED_WRITE_RULES,
+ APP_SERVICE_KEY_ALIASES,
+ READ_ONLY_CONFIG_SECTIONS,
+ BUNDLED_DEPLOYMENT_ONLY_PATHS,
+} from './config';
import { KEYED_COLLECTIONS } from './collections';
/** Every flag of the admin config contract the panel reads. */
@@ -30,6 +36,8 @@ export const CONFIG_FEATURE_FLAGS: readonly t.ConfigFeatureFlag[] = [
'yamlSecretPreview',
'envAllowlist',
'secretRebinding',
+ 'writeReport',
+ 'issueParams',
];
/** A server without the meta route (or one that leaves a flag out) has none of these behaviours. */
@@ -81,6 +89,7 @@ export const BUNDLED_CONFIG_RULES: t.ConfigRules = {
.map(([section]) => section),
basePrincipalSections: [...BASE_PRINCIPAL_CONFIG_SECTIONS],
baseOnlyPaths: [],
+ deploymentOnlyPaths: BUNDLED_DEPLOYMENT_ONLY_PATHS,
arrayMergeKeys: Object.fromEntries(
keyedCollections
.filter((collection) => collection.merge === 'byKey')
@@ -125,3 +134,18 @@ export const BUNDLED_CONFIG_CONTRACT: t.ConfigContract = {
limits: BUNDLED_CONFIG_LIMITS,
rules: BUNDLED_CONFIG_RULES,
};
+
+const WRITE_RULES_CACHE = new WeakMap();
+
+/**
+ * Where the panel may write on the contract's server (`resolveWriteRules`),
+ * built once per contract. The bundled contract keeps the panel's own lists.
+ */
+export function getWriteRules(contract: t.ConfigContract): t.ConfigWriteRules {
+ if (contract === BUNDLED_CONFIG_CONTRACT) return BUNDLED_WRITE_RULES;
+ const cached = WRITE_RULES_CACHE.get(contract);
+ if (cached) return cached;
+ const rules = resolveWriteRules(contract.rules, contract.features.preAuthBaseOverrides);
+ WRITE_RULES_CACHE.set(contract, rules);
+ return rules;
+}
diff --git a/src/constants/features.ts b/src/constants/features.ts
index 50c50a04..6f39e322 100644
--- a/src/constants/features.ts
+++ b/src/constants/features.ts
@@ -34,12 +34,29 @@ export function getDomainListPolicy(path: string): t.DomainListPolicy {
return DOMAIN_LIST_POLICIES[path] ?? DEFAULT_DOMAIN_POLICY;
}
-/** Keys LibreChat's admin API stores as typed: they are not in its secret registry. */
+/** Keys LibreChat's admin API stores as typed until `webSearchStoredKeys` adds them to its secret registry. */
const PLAINTEXT_KEYS: ReadonlySet = new Set(['keenableApiKey']);
function credential(key: string, env: string, required: boolean): t.WebSearchCredential {
const secret = /ApiKey$/.test(key);
- return { key, env, required, secret, encrypted: secret && !PLAINTEXT_KEYS.has(key) };
+ return {
+ key,
+ env,
+ required,
+ secret,
+ ...(secret && PLAINTEXT_KEYS.has(key) && { encryptedSince: 'webSearchStoredKeys' as const }),
+ };
+}
+
+/**
+ * Whether LibreChat stores the credential encrypted, with a masked preview:
+ * every web search key, and Keenable's only on a server with `webSearchStoredKeys`.
+ */
+export function isEncryptedCredential(
+ { secret, encryptedSince }: t.WebSearchCredential,
+ features: Pick,
+): boolean {
+ return secret && (encryptedSince === undefined || features[encryptedSince]);
}
const SERPER_KEY = credential('serperApiKey', 'SERPER_API_KEY', true);
diff --git a/src/constants/scope.ts b/src/constants/scope.ts
index 7f13b62f..1693dbfd 100644
--- a/src/constants/scope.ts
+++ b/src/constants/scope.ts
@@ -1,6 +1,6 @@
import { PrincipalType } from 'librechat-data-provider';
import type * as t from '@/types';
-import { DEDICATED_CONFIG_SECTIONS, READ_ONLY_CONFIG_SECTIONS, getConfigSection } from './config';
+import { BUNDLED_WRITE_RULES, DEDICATED_CONFIG_SECTIONS, getConfigSection } from './config';
export const SCOPE_TYPE_CONFIG: Record = {
BASE: {
@@ -44,27 +44,24 @@ export const DEFAULT_SCOPE_PRIORITY: Readonly = new Set(['registration']);
/**
- * Top-level sections that cannot be set per profile, with the reason (WS8).
- * In a profile they render as an "Applies to everyone — edit in Base" card and
- * their paths are never sent.
+ * Why `path` (or its section) cannot be set per profile, if it can't (WS8):
+ * a section read before sign-in, a tenant-wide one, or one read-only
+ * everywhere (by the server's write `rules`). In a profile they render as an
+ * "Applies to everyone — edit in Base" card and their paths are never sent.
*/
-export const SCOPE_GATED_SECTIONS: ReadonlyMap = new Map<
- string,
- t.ScopeGateReason
->([
- ...READ_ONLY_CONFIG_SECTIONS,
- ...[...DEDICATED_CONFIG_SECTIONS].map((section): [string, t.ScopeGateReason] => [
- section,
- 'tenantWide',
- ]),
- ...PRE_AUTH_SECTIONS.map((section): [string, t.ScopeGateReason] => [section, 'preAuth']),
-]);
-
-/** Why `path` (or its section) cannot be set per profile, if it can't. */
-export function getScopeGateReason(path: string): t.ScopeGateReason | undefined {
- return SCOPE_GATED_SECTIONS.get(getConfigSection(path));
+export function getScopeGateReason(
+ path: string,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): t.ScopeGateReason | undefined {
+ const section = getConfigSection(path);
+ if (PRE_AUTH_SECTIONS.has(section)) return 'preAuth';
+ if (DEDICATED_CONFIG_SECTIONS.has(section)) return 'tenantWide';
+ return rules.readOnlySections.get(section);
}
diff --git a/src/contexts/GlobalRevealContext.ts b/src/contexts/GlobalRevealContext.ts
index ca1cc9a2..79801af7 100644
--- a/src/contexts/GlobalRevealContext.ts
+++ b/src/contexts/GlobalRevealContext.ts
@@ -3,7 +3,7 @@ import { createContext } from 'react';
/**
* Opens a Global overrides (`endpoints.all`) setting: the configuration page
* provides it, so an "Overridden by Global" chip on any tab (a provider card,
- * a custom endpoint) links to the value that wins (D11). Without a provider
+ * a custom endpoint) links to the setting that wins (D11). Without a provider
* the chip has no link.
*/
export const GlobalRevealContext = createContext<((path: string) => void) | null>(null);
diff --git a/src/hooks/config/contract.ts b/src/hooks/config/contract.ts
index aa7bee95..ebf95608 100644
--- a/src/hooks/config/contract.ts
+++ b/src/hooks/config/contract.ts
@@ -2,6 +2,7 @@ import { useContext, useCallback } from 'react';
import type * as t from '@/types';
import { ConfigContractContext } from '@/contexts/ContractContext';
import { useLocalize } from '../useLocalize';
+import { getWriteRules } from '@/constants';
import { isUnsafeConfigKey } from '@/utils';
/** The connected server's admin config contract (the bundled one until it loads, or without one). */
@@ -14,6 +15,11 @@ export function useConfigFeatures(): t.ConfigFeatures {
return useContext(ConfigContractContext).features;
}
+/** Where the panel may write on the connected server: read-only sections and librechat.yaml-only fields. */
+export function useConfigWriteRules(): t.ConfigWriteRules {
+ return getWriteRules(useContext(ConfigContractContext));
+}
+
/**
* 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).
diff --git a/src/hooks/config/field.ts b/src/hooks/config/field.ts
index e4fb2f83..ddbd22ab 100644
--- a/src/hooks/config/field.ts
+++ b/src/hooks/config/field.ts
@@ -157,9 +157,14 @@ export function useRecordEntryOverrides(
}, [layers, recordPath]);
}
-/** Whether `endpoints.all` exists, so LibreChat reads title settings only from it (D11). */
+/**
+ * Whether `endpoints.all` exists on a server that reads title settings from
+ * it as one block (D11), so it is the only place they apply. A server that
+ * resolves them per field (`titleResolution: 'field'`) has no such block.
+ */
export function useGlobalTitleBlock(): boolean {
const data = useContext(ConfigValuesContext);
+ if (data?.titleResolution === 'field') return false;
const root = data?.configValues ?? data?.layers?.effective ?? null;
return isConfigRecord(getValueAtPath(root, 'endpoints.all'));
}
diff --git a/src/hooks/config/import.test.tsx b/src/hooks/config/import.test.tsx
index e8772fc5..a40b8429 100644
--- a/src/hooks/config/import.test.tsx
+++ b/src/hooks/config/import.test.tsx
@@ -306,9 +306,9 @@ describe('useImportPreview', () => {
});
describe('useConfigImport', () => {
- function renderImport(skipped: t.SkippedChange[]) {
+ function renderImport(skipped: t.SkippedChange[], reported: t.SkippedChange[] = []) {
const client = new QueryClient();
- const writeChanges = vi.fn().mockResolvedValue(skipped);
+ const writeChanges = vi.fn().mockResolvedValue({ skipped, reported });
const describeSkipped = (items: t.SkippedChange[]) =>
items.map((item) => item.fieldPath).join(', ');
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -354,4 +354,37 @@ describe('useConfigImport', () => {
}),
);
});
+
+ it('counts an entry whose credential the server left out as imported, noting the credential', async () => {
+ const toast = vi.mocked(createToast);
+ toast.mockClear();
+ const credential = {
+ fieldPath: 'mcpServers.github.headers.Authorization',
+ reason: 'secretRebinding',
+ } as const;
+ const { result } = renderImport([], [credential]);
+ await act(() => result.current.handleImport({ target: { type: 'base' }, entries }));
+ expect(toast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'warning',
+ title: 'Imported 2 values into the base configuration',
+ description: 'mcpServers.github.headers.Authorization',
+ }),
+ );
+ });
+
+ it('counts a change reported twice once', async () => {
+ const toast = vi.mocked(createToast);
+ toast.mockClear();
+ const notStored = { fieldPath: 'balance.startBalance', reason: 'notStored' } as const;
+ const { result } = renderImport([notStored, 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 86db70af..85d46dde 100644
--- a/src/hooks/config/import.ts
+++ b/src/hooks/config/import.ts
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import type * as t from '@/types';
import {
hasDottedKey,
+ uniqueSkipped,
notifySuccess,
notifyWarning,
flattenObject,
@@ -40,7 +41,7 @@ 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 { useConfigFeatures, useConfigWriteRules } from './contract';
import { useLocalize } from '../useLocalize';
/** Lists LibreChat merges with librechat.yaml by name; an import upserts their entries by name. */
@@ -229,6 +230,7 @@ export function useImportPreview({
}: t.ImportPreviewOptions): t.ImportPreviewState {
const localize = useLocalize();
const features = useConfigFeatures();
+ const writeRules = useConfigWriteRules();
const { data: base, isLoading: baseLoading } = useQuery(baseConfigOptions);
const existingScope = target?.type === 'scope' && target.exists ? target.scope : undefined;
const { data: scopeData, isLoading: scopeLoading } = useQuery(
@@ -241,9 +243,9 @@ export function useImportPreview({
const plan = useMemo(
() =>
appConfig
- ? planImport(appConfig, schemaTree, schemaPathSet, isScopeTarget, features)
+ ? planImport(appConfig, schemaTree, schemaPathSet, isScopeTarget, features, writeRules)
: undefined,
- [appConfig, schemaTree, schemaPathSet, isScopeTarget, features],
+ [appConfig, schemaTree, schemaPathSet, isScopeTarget, features, writeRules],
);
/** librechat.yaml and the base overrides as layers, so a value is labelled by the layer that sets it. */
@@ -437,10 +439,10 @@ export function useImportPreview({
({ fieldPath, value }): t.ConfigChange => ({ op: 'set', path: fieldPath, value }),
);
if (ops.length === 0) return [];
- const { issues } = planConfigChanges(ops, snapshot, features);
+ const { issues } = planConfigChanges(ops, snapshot, features, writeRules);
return describeIssues(issues, [], localize).summaries;
},
- [snapshot, entriesFor, features, localize],
+ [snapshot, entriesFor, features, writeRules, localize],
);
return {
@@ -496,11 +498,14 @@ export function useConfigImport({
queryClient.invalidateQueries({ queryKey: ['groups'] }),
]);
}
+ const result = 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(
+ const lost = uniqueSkipped(result.skipped).filter(
(item) => !MERGED_BACK_REASONS.has(item.reason),
);
- const written = Math.max(0, entries.length - skipped.length);
+ /** A part of an entry the server left out (a dropped credential) is noted; the entry is still written. */
+ const notes = [...lost, ...result.reported];
+ const written = Math.max(0, entries.length - lost.length);
const message =
writeTarget.type === 'scope'
? localize('com_config_import_profile_success', {
@@ -508,13 +513,13 @@ export function useConfigImport({
name: `${scopeTypeLabel(writeTarget.scope, localize)}: ${writeTarget.scope.name}`,
})
: localize('com_config_import_base_success', { count: written });
- if (skipped.length === 0) {
+ if (notes.length === 0) {
notifySuccess(message);
return;
}
const title =
- written > 0 ? message : localize('com_config_nothing_stored', { count: skipped.length });
- notifyWarning(title, describeSkipped(skipped));
+ written > 0 ? message : localize('com_config_nothing_stored', { count: lost.length });
+ notifyWarning(title, describeSkipped(notes));
},
[localize, queryClient, resolveTarget, writeChanges, describeSkipped],
);
diff --git a/src/hooks/config/layers.ts b/src/hooks/config/layers.ts
index bc38037d..4e77426b 100644
--- a/src/hooks/config/layers.ts
+++ b/src/hooks/config/layers.ts
@@ -16,6 +16,7 @@ import {
overriddenEntryKeys,
} from '@/utils';
import { KEYED_COLLECTIONS } from '@/constants';
+import { useConfigWriteRules } from './contract';
import { collectFieldPaths, withLangfuseConfiguredPath } from '@/components/configuration/counts';
import { mergeIndexedArrayEdits } from '@/components/configuration/edits';
import { getFieldMeta } from '@/components/configuration/meta';
@@ -101,13 +102,14 @@ export function useConfigLayers({
const scopeQuery = useQuery(resolvedConfigOptions(selectedScope));
const scopeData = isEditingScope ? scopeQuery.data : undefined;
+ const writeRules = useConfigWriteRules();
/** Profile mode: Base merged with the profile's overrides, minus its tombstones (null until both load). */
const valueLayers = useMemo((): t.ValueLayers | null => {
if (!baseLayers || !isEditingScope) return baseLayers;
if (!openScope || !scopeData) return null;
- return withScopeLayer(baseLayers, openScope, scopeData, schemaPathSet);
- }, [baseLayers, isEditingScope, openScope, scopeData, schemaPathSet]);
+ return withScopeLayer(baseLayers, openScope, scopeData, schemaPathSet, writeRules);
+ }, [baseLayers, isEditingScope, openScope, scopeData, schemaPathSet, writeRules]);
/**
* Overridden entries of each keyed list, by the edit target: Base's
diff --git a/src/hooks/config/options.test.tsx b/src/hooks/config/options.test.tsx
index 1a55cec1..52b640d9 100644
--- a/src/hooks/config/options.test.tsx
+++ b/src/hooks/config/options.test.tsx
@@ -164,6 +164,15 @@ describe('buildEndpointOptions', () => {
expect(options[0].value).toBe('ollama');
});
+ it('offers it as written on a server that folds endpoint references', () => {
+ const status: t.EndpointStatusMap = {
+ ollama: { name: 'ollama', custom: true, userProvide: false },
+ };
+ const root = { endpoints: { custom: [{ name: 'Ollama' }] } };
+ const options = buildEndpointOptions(status, root, (key) => key, false, true);
+ expect(options[0]).toEqual({ value: 'ollama', label: 'Ollama' });
+ });
+
it('keeps endpoints whose names differ only in letter case apart (LibreChat serves both)', () => {
const status: t.EndpointStatusMap = {
Perplexity: { name: 'Perplexity', custom: true, userProvide: false },
diff --git a/src/hooks/config/options.ts b/src/hooks/config/options.ts
index b8d195da..b7e11bcf 100644
--- a/src/hooks/config/options.ts
+++ b/src/hooks/config/options.ts
@@ -10,7 +10,8 @@ import {
} from '@/server';
import { ConfigValuesContext } from '@/contexts/ConfigValuesContext';
import { MODEL_PROVIDERS, BUILT_IN_PROVIDERS } from '@/constants';
-import { getValueAtPath, isConfigRecord } from '@/utils';
+import { getValueAtPath, isConfigRecord, foldsEndpointReferences } from '@/utils';
+import { useConfigContract } from './contract';
import { useLocalize } from '../useLocalize';
import { useFieldState } from './field';
@@ -105,8 +106,10 @@ export function useCustomEndpointNames(): string[] {
* saved yet), so a reference to any of them is recognized. Each value is the
* name LibreChat serves the endpoint under, since `addedEndpoints` and
* `allowedProviders` are matched against it exactly: a custom endpoint
- * written "Ollama" is offered as "Ollama (ollama)". Names that differ only
- * in letter case are distinct endpoints and are both offered. With
+ * written "Ollama" is offered as "Ollama (ollama)". A server that folds
+ * references to the served name (`foldsNames`) matches either spelling, so
+ * the endpoint is offered as written ("Ollama"). Names that differ only in
+ * letter case are distinct endpoints and are both offered. With
* `providersOnly`, endpoints that delegate to a provider (agents, Assistants)
* are left out.
*/
@@ -115,6 +118,7 @@ export function buildEndpointOptions(
root: t.ConfigValue,
localize: t.LocalizeFn,
providersOnly = false,
+ foldsNames = false,
): t.SelectOption[] {
const configured = customEndpointNames(getValueAtPath(root, 'endpoints.custom'));
const spelling = new Map();
@@ -131,7 +135,8 @@ export function buildEndpointOptions(
const label = BUILT_IN_SET.has(served)
? localize(`com_config_field_${served}`)
: (spelling.get(served) ?? served);
- options.push({ value: served, label: label === served ? served : `${label} (${served})` });
+ const shownLabel = label === served || foldsNames ? label : `${label} (${served})`;
+ options.push({ value: served, label: shownLabel });
};
for (const name of Object.keys(status ?? {})) add(name);
for (const name of BUILT_IN_PROVIDERS) add(name);
@@ -181,6 +186,7 @@ function toOptions(values: string[]): t.SelectOption[] {
*/
export function useOptionSources(source?: t.OptionSource): t.OptionSourceResult {
const localize = useLocalize();
+ const foldsNames = foldsEndpointReferences(useConfigContract());
const root = useShownConfig();
const { client, live } = useClient();
const version = useConfigVersion(client, live);
@@ -208,14 +214,14 @@ export function useOptionSources(source?: t.OptionSource): t.OptionSourceResult
const options = useMemo((): t.SelectOption[] => {
if (!source) return NO_OPTIONS;
if (source === 'endpoints' || source === 'providers') {
- return buildEndpointOptions(status.data, root, localize, source === 'providers');
+ return buildEndpointOptions(status.data, root, localize, source === 'providers', foldsNames);
}
if (source === 'mcpServers') return toOptions(recordKeys(root, 'mcpServers'));
if (source === 'specGroups') return toOptions(specGroups(root));
if (source === 'tools') return tools.data ?? NO_OPTIONS;
const endpoint = source.slice(MODELS_PREFIX.length);
return toOptions(modelsOf(models.data, endpoint) ?? []);
- }, [source, status.data, models.data, tools.data, root, localize]);
+ }, [source, status.data, models.data, tools.data, root, localize, foldsNames]);
const isLoading =
live &&
diff --git a/src/hooks/config/review.test.tsx b/src/hooks/config/review.test.tsx
index ac0de4dc..62286478 100644
--- a/src/hooks/config/review.test.tsx
+++ b/src/hooks/config/review.test.tsx
@@ -8,6 +8,9 @@ import { extractSchemaTree, getSchemaPathSet } from '@/server/config';
import { useChangeReview } from './review';
import { useConfigEdits } from './edits';
import { flattenObject } from '@/utils';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { createServerContract } from '@/test/fixtures';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
import { useConfigSave } from './save';
import '@/locales/i18n';
@@ -33,10 +36,13 @@ function renderPage(
config: t.ConfigRecord = yaml,
dbOverrides: t.ConfigRecord = {},
yamlConfig: t.ConfigRecord = config,
+ contract: t.ConfigContract = BUNDLED_CONFIG_CONTRACT,
) {
const client = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
- {children}
+
+ {children}
+
);
return renderHook(
() => {
@@ -126,6 +132,30 @@ describe('the change review over the save pipeline (WS1 × WS7)', () => {
]);
});
+ it('warns per field on a server that resolves titles per field, the endpoint and model together', () => {
+ const config = {
+ ...yaml,
+ endpoints: { ...(yaml.endpoints as t.ConfigRecord), all: { titleEndpoint: 'anthropic' } },
+ };
+ const { result } = renderPage(
+ config,
+ {},
+ config,
+ createServerContract({}, { titleResolution: 'field' }),
+ );
+ act(() => {
+ result.current.edits.handleFieldChange('endpoints.openAI.titleModel', 'gpt-x');
+ result.current.edits.handleFieldChange('endpoints.openAI.titleMethod', 'completion');
+ });
+ const rows = result.current.review.model.tabs.flatMap((tab) =>
+ tab.sections.flatMap((section) => section.rows),
+ );
+ expect(rows.map((row) => [row.item.path, row.item.warnings])).toEqual([
+ ['endpoints.openAI.titleModel', ['shadowedPair']],
+ ['endpoints.openAI.titleMethod', undefined],
+ ]);
+ });
+
it("blocks Save on the pipeline's issue when a librechat.yaml entry is removed (D1)", () => {
const { result } = renderPage();
act(() => result.current.edits.handleFieldChange('endpoints.custom', [moonshot]));
diff --git a/src/hooks/config/review.ts b/src/hooks/config/review.ts
index 424237e4..e336a481 100644
--- a/src/hooks/config/review.ts
+++ b/src/hooks/config/review.ts
@@ -29,10 +29,19 @@ import { overrideCountUnit } from '@/components/configuration/counts';
import { planRevert } from '@/components/configuration/review/revert';
import { findSchemaField } from '@/components/configuration/controls';
import { normalizeForSave } from '@/components/configuration/edits';
+import { useConfigContract } from './contract';
import { useLocalize } from '../useLocalize';
const NO_ISSUES: t.ReviewIssue[] = [];
+/** The review warning of a change Global overrides take precedence over, by how they win. */
+const SHADOW_WARNINGS: Readonly, t.ChangeWarning>> =
+ {
+ block: 'shadowedBlock',
+ field: 'shadowed',
+ pair: 'shadowedPair',
+ };
+
/** Where Go to field lands: a removed entry is no longer on the page, so its list. */
function goToPath(item: t.ChangeSummaryItem): string {
if (item.kind === 'removed' && item.entryPath === item.path && item.collectionPath) {
@@ -65,6 +74,7 @@ export function useChangeReview({
onTabChange,
}: t.ChangeReviewOptions): t.ChangeReviewState {
const localize = useLocalize();
+ const { titleResolution } = useConfigContract().rules;
const isScope = editingScope !== undefined;
const { savePlan, fieldErrors, changes, confirmSaveOpen: open } = save;
const {
@@ -157,18 +167,17 @@ export function useChangeReview({
const found = drift.get(item.path);
const shadowedBy =
item.after !== undefined
- ? resolveShadowedBy(item.path, baseline, edits.editedValues)
+ ? resolveShadowedBy(item.path, baseline, edits.editedValues, titleResolution)
: undefined;
if (!found && !shadowedBy) return item;
- const shadowed: t.ChangeWarning =
- shadowedBy?.precedence === 'block' ? 'shadowedBlock' : 'shadowed';
+ const shadowed = SHADOW_WARNINGS[shadowedBy?.precedence ?? 'field'];
return {
...item,
...(found && { drift: found }),
...(shadowedBy && { warnings: [...(item.warnings ?? []), shadowed] }),
};
});
- }, [input, changes, visibilityItems, baseline, edits.editedValues]);
+ }, [input, changes, visibilityItems, baseline, edits.editedValues, titleResolution]);
const labelContext = useMemo(
() => ({ localize, fields: schemaTree, tree: applySaves(baseline, savePlan.saves) }),
diff --git a/src/hooks/config/save.ts b/src/hooks/config/save.ts
index f47a476f..60747e07 100644
--- a/src/hooks/config/save.ts
+++ b/src/hooks/config/save.ts
@@ -5,6 +5,7 @@ import {
errorText,
notifyInfo,
notifyError,
+ uniqueSkipped,
notifyWarning,
notifySuccess,
getValueAtPath,
@@ -25,7 +26,7 @@ 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 { useConfigFeatures, useConfigWriteRules } from './contract';
import { useLocalize } from '../useLocalize';
function toTargetRef(target: t.WriteTarget): t.WriteTargetRef {
@@ -54,17 +55,6 @@ function termsUrlOf(result: t.ApplyConfigChangesResult): string | null {
return typeof issue.params?.url === 'string' ? issue.params.url : '';
}
-/** Skipped changes without repeats (the client and the server can report the same path). */
-function uniqueSkipped(skipped: t.SkippedChange[]): t.SkippedChange[] {
- const seen = new Set();
- return skipped.filter(({ fieldPath, reason }) => {
- const key = `${fieldPath}|${reason}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
-
/**
* The save pipeline of the configuration page (WS1). Pending edits become
* `ConfigChange` ops (keyed collection entries by identity), which are
@@ -82,6 +72,7 @@ export function useConfigSave({
const localize = useLocalize();
const queryClient = useQueryClient();
const features = useConfigFeatures();
+ const writeRules = useConfigWriteRules();
const {
configValues,
dbOverrides,
@@ -214,7 +205,7 @@ export function useConfigSave({
if (result.issues.length > 0 || result.failed.length > 0) {
throw new Error(describeFailure(result, []));
}
- return result.skipped;
+ return { skipped: result.skipped, reported: result.reported };
},
[applyChanges, describeFailure],
);
@@ -228,6 +219,7 @@ export function useConfigSave({
root: baseActiveConfigValues,
collections: collectionEdits,
visibility: visibilityEdits,
+ writeRules,
}),
[
schemaTree,
@@ -236,6 +228,7 @@ export function useConfigSave({
isEditingScope,
collectionEdits,
visibilityEdits,
+ writeRules,
],
);
@@ -263,8 +256,8 @@ export function useConfigSave({
}, [isEditingScope, valueLayers, configValues, dbOverrides, yamlConfig]);
const plan = useMemo(
- () => planConfigChanges(changeSet.ops, snapshot, features),
- [changeSet, snapshot, features],
+ () => planConfigChanges(changeSet.ops, snapshot, features, writeRules),
+ [changeSet, snapshot, features, writeRules],
);
const clientIssues = useMemo(
@@ -443,7 +436,7 @@ export function useConfigSave({
}
clearEdits();
announceWrite(
- [...changeSet.payload.skipped, ...result.skipped],
+ [...changeSet.payload.skipped, ...result.skipped, ...result.reported],
result.applied,
localize('com_config_saved'),
);
diff --git a/src/hooks/config/scope.ts b/src/hooks/config/scope.ts
index 5cff29f8..fa923f90 100644
--- a/src/hooks/config/scope.ts
+++ b/src/hooks/config/scope.ts
@@ -11,6 +11,7 @@ import {
} from '@/utils';
import { ScopeActionsContext } from '@/contexts/ScopeContext';
import { availableScopesOptions } from '@/server';
+import { useConfigWriteRules } from './contract';
const EMPTY_SCOPES: t.ConfigScope[] = [];
@@ -132,5 +133,9 @@ export function useScopeGatedSections(
sections: t.ConfigSectionConfig[],
isEditingScope: boolean,
): t.ConfigSectionConfig[] {
- return useMemo(() => applyScopeGates(sections, isEditingScope), [sections, isEditingScope]);
+ const writeRules = useConfigWriteRules();
+ return useMemo(
+ () => applyScopeGates(sections, isEditingScope, writeRules),
+ [sections, isEditingScope, writeRules],
+ );
}
diff --git a/src/hooks/config/sections.ts b/src/hooks/config/sections.ts
index 31381922..322d62cf 100644
--- a/src/hooks/config/sections.ts
+++ b/src/hooks/config/sections.ts
@@ -9,7 +9,8 @@ import {
} from '@/components/configuration/configMeta';
import { providerTocItems } from '@/components/configuration/sections/providers';
import { webSearchTocItems } from '@/components/configuration/sections/features';
-import { INTERFACE_GROUPS, READ_ONLY_CONFIG_SECTIONS } from '@/constants';
+import { useConfigWriteRules } from './contract';
+import { INTERFACE_GROUPS } from '@/constants';
import { getValueAtPath, isConfigRecord } from '@/utils';
import { useLocalize } from '../useLocalize';
@@ -126,6 +127,7 @@ export function useTabSections({
editedValues,
}: t.TabSectionsOptions): t.ConfigSectionConfig[] {
const localize = useLocalize();
+ const writeRules = useConfigWriteRules();
return useMemo((): t.ConfigSectionConfig[] => {
// Collect virtual section entries (those with schemaKey) that target this tab
@@ -144,7 +146,7 @@ export function useTabSections({
const children = section.children ?? [];
const hasStructuredChildren =
(section.isObject || section.type === 'record') && children.length > 0;
- const readOnlyReason = READ_ONLY_CONFIG_SECTIONS.get(section.key);
+ const readOnlyReason = writeRules.readOnlySections.get(section.key);
return {
id: section.key,
titleKey: meta?.titleKey ?? `com_config_section_${section.key}`,
@@ -204,5 +206,13 @@ export function useTabSections({
}
return allSections;
- }, [schemaTree, activeTab, activeConfigValues, editedValues, localize, sectionPermissions]);
+ }, [
+ schemaTree,
+ activeTab,
+ activeConfigValues,
+ editedValues,
+ localize,
+ sectionPermissions,
+ writeRules,
+ ]);
}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 20b0a33b..a75f456a 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -634,6 +634,7 @@
"com_config_review_warning_empty_allowlist": "An empty list allows nothing. Remove the override instead to go back to the default.",
"com_config_review_warning_open_allowlist": "An empty list sets no limit, so everything this list restricts is allowed. Remove the override instead to go back to the list beneath.",
"com_config_review_warning_shadowed": "Global overrides set this and take precedence, so this change has no effect while they do",
+ "com_config_review_warning_shadowed_pair": "Global overrides choose the title endpoint and model together and set the other one, so this change has no effect while they do",
"com_config_review_warning_shadowed_block": "While Global overrides exist, LibreChat reads title settings only from them, so this change has no effect",
"com_config_review_warning_ownership": "This list will now be managed in the admin panel: changes to it in librechat.yaml won't apply until you revert it.",
"com_config_review_warning_ownership_scope": "This list replaces the whole list these users inherit from Base.",
@@ -646,6 +647,17 @@
"com_config_skip_not_stored": "LibreChat did not store it; configure it in librechat.yaml",
"com_config_skip_not_applied": "saved, but LibreChat still uses the librechat.yaml value",
"com_config_skip_yaml_merged": "LibreChat merges this librechat.yaml value back in; it can't be removed here",
+ "com_config_skip_process_server": "this MCP server runs a local process from librechat.yaml, so LibreChat applies no overrides to it",
+ "com_config_skip_secret_rebinding": "not sent: where it goes changed without the credential being entered again. Enter it again to use it",
+ "com_config_api_error_auth_required": "Your admin session expired. Sign in again.",
+ "com_config_api_error_config_key_not_storable": "LibreChat's database can't store a key that contains a dot. Rename the key, or set it in librechat.yaml.",
+ "com_config_api_error_config_not_found": "This profile no longer exists. It may have been deleted.",
+ "com_config_api_error_config_version_conflict": "The configuration changed while saving. Reload the page and try again.",
+ "com_config_api_error_forbidden": "You don't have permission to make this change.",
+ "com_config_api_error_forbidden_section": "You don't have permission to change the {{section}} settings.",
+ "com_config_api_error_invalid_priority": "The priority must be a number of 0 or more.",
+ "com_config_api_error_principal_not_found": "The role, group or user this profile is for doesn't exist (it may have been deleted or renamed).",
+ "com_config_api_error_process_mcp_server_override": "This MCP server runs a local process defined in librechat.yaml, so LibreChat applies no overrides to it.",
"com_config_apply_failed": "Save failed and nothing was changed",
"com_config_apply_not_effective_one": "Saved, but {{count}} change did not take effect",
"com_config_apply_not_effective_other": "Saved, but {{count}} changes did not take effect",
@@ -691,6 +703,11 @@
"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_not_multiple_of": "must be a multiple of {{multipleOf}}",
+ "com_config_issue_custom_empty": "is empty: enter a host and a port",
+ "com_config_issue_custom_format": "must be only host:port, without http:// or a path",
+ "com_config_issue_custom_missing_port": "must include a port, for example localhost:11434",
+ "com_config_issue_custom_public_ip": "is a public IP address: enter a private address, or a hostname that resolves to one",
"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",
@@ -1041,6 +1058,7 @@
"com_config_providers_desc_unused_baseURL": "Not used for built-in providers: LibreChat reads the base URL from the server environment (for example OPENAI_REVERSE_PROXY).",
"com_config_providers_desc_unused_models": "Not used: LibreChat lists the models the Assistants API returns.",
"com_config_providers_desc_vertex": "Run Anthropic models through Google Vertex AI.",
+ "com_config_providers_global_desc_field": "Applies to every provider and custom endpoint. A title, activity or reasoning label setting set here takes precedence over each endpoint's own; the title endpoint and model are chosen together.",
"com_config_providers_global_desc": "Applies to every provider and custom endpoint. Activity and reasoning label settings set here take precedence over each endpoint's own. While this block exists, LibreChat reads title settings only from here.",
"com_config_providers_global_short": "Global overrides",
"com_config_providers_global_title": "Global overrides (all endpoints)",
@@ -1099,6 +1117,7 @@
"com_config_providers_recursion_capped": "Max recursion limit ({{max}}) is below the recursion limit ({{limit}}), so agents get at most {{max}} steps per run.",
"com_config_providers_shadow_block": "While Global overrides exist, LibreChat reads title settings only from them (Global {{field}}: {{value}}). This value ({{source}}) is ignored.",
"com_config_providers_shadow_block_unset": "While Global overrides exist, LibreChat reads title settings only from them. Global doesn't set {{field}}, so LibreChat's default applies and this value ({{source}}) is ignored.",
+ "com_config_providers_shadow_pair": "Global overrides choose the title endpoint and model together. They set {{field}} and leave this one unset, so LibreChat's default applies and this value ({{source}}) is ignored.",
"com_config_providers_shadow_field": "Global overrides set {{field}} to {{value}}, which takes precedence. This value ({{source}}) is ignored.",
"com_config_providers_shadow_go": "Go to {{field}} in Global overrides",
"com_config_providers_shadow_label": "Overridden by Global",
@@ -1128,6 +1147,7 @@
"com_config_providers_summary_set": "Set here: {{summary}}",
"com_config_providers_summary_shadowed": "{{value}} (Global wins)",
"com_config_providers_summary_shadowed_ignored": "{{value}} (ignored while Global exists)",
+ "com_config_providers_summary_shadowed_pair": "{{value}} (ignored: Global sets the title endpoint or model)",
"com_config_providers_vertex_none": "Vertex AI is not configured. Configure it in librechat.yaml.",
"com_config_providers_vertex_readonly": "Vertex AI settings are managed in librechat.yaml and shown read-only.",
"com_config_section_memory": "Memory",
@@ -2561,6 +2581,7 @@
"com_config_registration_domain_remove": "Remove {{domain}}",
"com_config_registration_domains_desc": "Only email addresses at these domains can sign up. Leave the list empty to let anyone sign up.",
"com_config_registration_domains_yaml_note": "Social and SSO sign-ins and password resets also have to pass the librechat.yaml list.",
+ "com_config_registration_domains_all_note": "Social, SSO and LDAP sign-ins and password resets check this list too.",
"com_config_registration_open_body": "With no allowed domains, anyone with an email address can create an account.",
"com_config_registration_open_confirm": "Let anyone sign up",
"com_config_registration_open_title": "Let anyone sign up?",
@@ -2574,6 +2595,8 @@
"com_config_registration_provider_unknown": "{{name}} (not a sign-in option)",
"com_config_registration_restricted_one": "Sign-up is limited to {{count}} domain.",
"com_config_registration_restricted_other": "Sign-up is limited to {{count}} domains.",
+ "com_config_registration_social_add": "Add sign-in option",
+ "com_config_registration_social_options": "Sign-in options",
"com_config_registration_social_desc": "Social sign-in buttons on the sign-in page, in this order. Each one also needs its credentials in the server's .env.",
"com_config_registration_social_none": "No social sign-in buttons",
"com_config_registration_social_yaml": "The sign-in page reads this list from librechat.yaml only, so it can't be changed here.",
@@ -2813,7 +2836,15 @@
"com_scope_overrides_revert_hint": "Removing an override marks it like the field's Reset: review and save to apply it, and the setting falls back to Base.",
"com_scope_overrides_title": "Overrides in {{name}}",
"com_scope_precedence": "Higher priority wins: by default users (100) override groups (20), which override roles (10), which override Base.",
+ "com_scope_precedence_ordered": "Base applies first. Then higher priority wins: by default users (100) override groups (20), which override roles (10). On equal priority, users win over groups and groups over roles.",
"com_scope_priority_help": "Higher priority wins. By default users (100) override groups (20), groups override roles (10), and every profile overrides Base.",
+ "com_scope_priority_help_ordered": "Base applies first, whatever its priority. Profiles then apply from lowest to highest priority, so higher priority wins; on equal priority, roles apply before groups and groups before users. By default users (100) override groups (20), which override roles (10).",
+ "com_scope_priority_change": "Change",
+ "com_scope_priority_change_label": "Change priority",
+ "com_scope_priority_label": "Priority",
+ "com_scope_priority_invalid": "Enter a whole number.",
+ "com_scope_priority_range": "Enter a priority from 0 to {{max}}.",
+ "com_scope_priority_error": "Failed to change the priority",
"com_scope_search_profiles": "Search profiles…",
"com_scope_skip_base_scope": "applies to everyone, so it can only be set in the Base configuration",
"com_scope_skip_inherited_value": "set in Base; a profile can only remove its own override",
@@ -2822,6 +2853,11 @@
"com_scope_tie_conflict_other": "Same priority as {{names}}, and both change {{count}} settings. For users in both, which value applies is not defined.",
"com_scope_tie_note": "Same priority as {{names}}; they don't change the same settings.",
"com_scope_tie_short": "Same priority as {{names}}, and both change the same settings",
+ "com_scope_tie_order_wins_one": "Same priority as {{names}}, and both change {{count}} setting. For users in both, this profile applies last, so its value wins.",
+ "com_scope_tie_order_wins_other": "Same priority as {{names}}, and both change {{count}} settings. For users in both, this profile applies last, so its values win.",
+ "com_scope_tie_order_loses_one": "Same priority as {{names}}, and both change {{count}} setting. For users in both, they apply after this profile, so their value wins.",
+ "com_scope_tie_order_loses_other": "Same priority as {{names}}, and both change {{count}} settings. For users in both, they apply after this profile, so their values win.",
+ "com_scope_tie_order_rule": "On equal priority, LibreChat applies roles, then groups, then users; profiles of the same kind apply in the order they were created.",
"com_scope_active_option": "Active: {{name}}",
"com_scope_toggle_error": "Failed to change whether the profile is active",
"com_scope_type_group": "Group",
diff --git a/src/server/config/meta.test.ts b/src/server/config/meta.test.ts
index 169731bd..bbe87042 100644
--- a/src/server/config/meta.test.ts
+++ b/src/server/config/meta.test.ts
@@ -87,6 +87,36 @@ describe('readConfigContract', () => {
expect(contract.rules.arrayMergeKeys).toEqual(BUNDLED_CONFIG_RULES.arrayMergeKeys);
});
+ it('reads deployment-only paths, an unknown reason as an operator boundary', async () => {
+ apiFetchMock.mockResolvedValue(
+ reply(200, {
+ features: { writeReport: true, issueParams: true },
+ rules: {
+ deploymentOnlyPaths: {
+ rateLimits: 'operator',
+ 'endpoints.agents.eventDriven': 'startup',
+ cache: 'unused',
+ futurePath: 'scheduled',
+ },
+ },
+ }),
+ );
+ const contract = await readConfigContract();
+ expect(contract.features).toMatchObject({ writeReport: true, issueParams: true });
+ expect(contract.rules.deploymentOnlyPaths).toEqual({
+ rateLimits: 'operator',
+ 'endpoints.agents.eventDriven': 'startup',
+ cache: 'unused',
+ futurePath: 'operator',
+ });
+ });
+
+ it('keeps the bundled deployment-only paths for a server that does not list them', async () => {
+ apiFetchMock.mockResolvedValue(reply(200, META));
+ const contract = await readConfigContract();
+ expect(contract.rules.deploymentOnlyPaths).toBe(BUNDLED_CONFIG_RULES.deploymentOnlyPaths);
+ });
+
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);
diff --git a/src/server/config/meta.ts b/src/server/config/meta.ts
index 8be121a1..3f0f9713 100644
--- a/src/server/config/meta.ts
+++ b/src/server/config/meta.ts
@@ -14,12 +14,15 @@ const text = z.string();
const names = z.array(z.string());
const count = z.number().int().nonnegative();
const nameMap = z.record(z.string(), z.string());
+/** A reason a newer server adds reads as an operator boundary: only librechat.yaml sets the path. */
+const deploymentReason = z.enum(['startup', 'operator', 'unused']).catch('operator');
const rulesSchema = z.object({
acceptedSections: lenient(names),
baseOnlySections: lenient(names),
basePrincipalSections: lenient(names),
baseOnlyPaths: lenient(names),
+ deploymentOnlyPaths: lenient(z.record(z.string(), deploymentReason)),
arrayMergeKeys: lenient(nameMap),
atomicMergePaths: lenient(names),
overrideKeyMap: lenient(nameMap),
diff --git a/src/server/config/save.test.ts b/src/server/config/save.test.ts
index f9e21637..f8c13fae 100644
--- a/src/server/config/save.test.ts
+++ b/src/server/config/save.test.ts
@@ -518,3 +518,276 @@ describe('applyConfigChanges — the server contract (GET /api/admin/config/meta
expect(api.current!.writes().map((request) => request.body?.entries?.length)).toEqual([2, 1]);
});
});
+
+describe('applyConfigChanges — write reports, structured issues and codes', () => {
+ const skippedPart = {
+ path: 'interface.prompts',
+ segments: ['interface', 'prompts'],
+ reason: 'interface_permission',
+ };
+ const skippedWhole = {
+ path: 'balance.refillAmount',
+ segments: ['balance', 'refillAmount'],
+ reason: 'deployment_only_path',
+ };
+
+ it('reports what the server did not store, counting only whole changes as not applied', async () => {
+ api.current!.reportMeta({ features: { writeReport: true } });
+ api.current!.reportWrites(() => ({ applied: [], skipped: [skippedWhole, skippedPart] }));
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 5 },
+ { op: 'set', path: 'balance.refillAmount', value: 6 },
+ ]);
+ expect(result).toMatchObject({ issues: [], failed: [], applied: 1 });
+ expect(result.skipped).toEqual([{ fieldPath: 'balance.refillAmount', reason: 'yamlOnly' }]);
+ expect(result.reported).toEqual([{ fieldPath: 'interface.prompts', reason: 'permission' }]);
+ });
+
+ it('leaves the report alone on a server without writeReport', async () => {
+ api.current!.reportWrites(() => ({ skipped: [skippedWhole] }));
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.refillAmount', value: 6 },
+ ]);
+ expect(result).toMatchObject({ applied: 1, skipped: [] });
+ });
+
+ it('reports a whole PATCH the server skipped, without a second not-stored finding', async () => {
+ api.current!.reportMeta({ features: { writeReport: true } });
+ api.current!.stripSection('balance');
+ api.current!.reportWrites((request) =>
+ request.method === 'PATCH' ? { applied: [], skipped: [skippedWhole] } : {},
+ );
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.refillAmount', value: 6 },
+ ]);
+ expect(result).toMatchObject({ applied: 0 });
+ expect(result.skipped).toEqual([{ fieldPath: 'balance.refillAmount', reason: 'yamlOnly' }]);
+ });
+
+ it('reports a tombstone the server skipped', async () => {
+ api.current!.reportMeta({ features: { writeReport: true } });
+ api.current!.reportWrites((request) =>
+ request.path.endsWith('/tombstone')
+ ? { skipped: [{ path: 'balance.refillAmount', reason: 'deployment_only_path' }] }
+ : {},
+ );
+ const result = await applyConfigChanges(base, [
+ { op: 'tombstone', path: 'balance.refillAmount' },
+ ]);
+ expect(result.skipped).toEqual([{ fieldPath: 'balance.refillAmount', reason: 'yamlOnly' }]);
+ expect(result.applied).toBe(0);
+ });
+
+ it('reports a credential the merge leaves out once the server rebinds credentials', async () => {
+ const ignored = [
+ {
+ path: 'endpoints.custom.Moonshot.apiKey',
+ segments: ['endpoints', 'custom', 'Moonshot', 'apiKey'],
+ reason: 'secret_rebinding',
+ },
+ ];
+ api.current!.reportWrites(() => ({ ignored }));
+ const moonshot = customEntries[0];
+ const change = upsert(
+ 'Moonshot',
+ 'Moonshot',
+ { ...moonshot, baseURL: 'https://proxy.example.com/v1' },
+ moonshot,
+ );
+ expect(await applyConfigChanges(base, [change])).toMatchObject({ skipped: [], reported: [] });
+ api.current = createFakeAdminApi(yaml);
+ api.current.reportMeta({ features: { secretRebinding: true } });
+ api.current.reportWrites(() => ({ ignored }));
+ const result = await applyConfigChanges(base, [change]);
+ expect(result).toMatchObject({ applied: 1, failed: [], skipped: [] });
+ expect(result.reported).toEqual([
+ { fieldPath: 'endpoints.custom.Moonshot.apiKey', reason: 'secretRebinding' },
+ ]);
+ const unrelated = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 5 },
+ ]);
+ expect(unrelated).toMatchObject({ applied: 1, skipped: [], reported: [] });
+ });
+
+ it('notes a dropped credential once, apart from the changes, however many PATCHes repeat it', async () => {
+ api.current!.reportMeta({
+ features: { secretRebinding: true },
+ limits: { maxPatchEntries: 1 },
+ });
+ api.current!.reportWrites(() => ({
+ ignored: [
+ {
+ path: 'endpoints.custom.Moonshot.apiKey',
+ segments: ['endpoints', 'custom', 'Moonshot', 'apiKey'],
+ reason: 'secret_rebinding',
+ },
+ ],
+ }));
+ const moonshot = customEntries[0];
+ const result = await applyConfigChanges(base, [
+ upsert('Moonshot', 'Moonshot', { ...moonshot, titleModel: 'kimi-k2' }, moonshot),
+ { op: 'set', path: 'balance.startBalance', value: 5 },
+ ]);
+ expect(api.current!.writes()).toHaveLength(2);
+ expect(result).toMatchObject({ applied: 2, failed: [], skipped: [] });
+ expect(result.reported).toEqual([
+ { fieldPath: 'endpoints.custom.Moonshot.apiKey', reason: 'secretRebinding' },
+ ]);
+ });
+
+ it("does not repeat a dropped credential on a later save of another endpoint's settings", async () => {
+ api.current!.reportMeta({ features: { secretRebinding: true } });
+ api.current!.reportWrites(() => ({
+ ignored: [
+ {
+ path: 'endpoints.custom.Moonshot.apiKey',
+ segments: ['endpoints', 'custom', 'Moonshot', 'apiKey'],
+ reason: 'secret_rebinding',
+ },
+ ],
+ }));
+ const [moonshot, gpt] = customEntries;
+ const title = await applyConfigChanges(base, [
+ { op: 'set', path: 'endpoints.openAI.titleModel', value: 'gpt-5-nano' },
+ ]);
+ expect(title).toMatchObject({ applied: 1, failed: [], skipped: [], reported: [] });
+ const other = await applyConfigChanges(base, [
+ upsert('GPT', 'GPT', { ...gpt, titleModel: 'gpt-4o-mini' }, gpt),
+ ]);
+ expect(other).toMatchObject({ applied: 1, failed: [], skipped: [], reported: [] });
+ expect(api.current!.writes().at(-1)).toMatchObject({
+ body: { entries: [{ fieldPath: 'endpoints.custom' }] },
+ });
+ const same = await applyConfigChanges(base, [
+ upsert('Moonshot', 'Moonshot', { ...moonshot, titleModel: 'kimi-k2' }, moonshot),
+ ]);
+ expect(same.reported).toEqual([
+ { fieldPath: 'endpoints.custom.Moonshot.apiKey', reason: 'secretRebinding' },
+ ]);
+ });
+
+ it('words a backend issue by its params on a server with issueParams', async () => {
+ const issue = {
+ path: 'balance.startBalance',
+ segments: ['balance', 'startBalance'],
+ code: 'too_small',
+ params: { minimum: 1, inclusive: true, exact: false, type: 'number' },
+ };
+ const refuse = (request: { method: string }) =>
+ request.method === 'PATCH'
+ ? { status: 400, body: { code: 'CONFIG_OVERRIDE_INVALID', issues: [issue] } }
+ : undefined;
+ api.current!.setOverride(refuse);
+ const before = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 0 },
+ ]);
+ expect(before.failed).toEqual([
+ { path: 'balance.startBalance', code: 'too_small', message: 'is too small' },
+ ]);
+ api.current!.reportMeta({ features: { issueParams: true } });
+ const after = await applyConfigChanges(base, [
+ { op: 'set', path: 'balance.startBalance', value: 0 },
+ ]);
+ expect(after.failed).toEqual([
+ {
+ path: 'balance.startBalance',
+ code: 'too_small',
+ message: 'must be at least 1',
+ params: { minimum: 1 },
+ },
+ ]);
+ });
+
+ it('locates an issue by its segments: a dotted record key stays one key', async () => {
+ const profiles = { 'us.anthropic.claude-sonnet-4-6': 'arn' };
+ api.current!.reportMeta({ features: { dottedKeys: true, issueParams: true } });
+ api.current!.setOverride((request) =>
+ request.method === 'PATCH'
+ ? {
+ status: 400,
+ body: {
+ code: 'CONFIG_OVERRIDE_INVALID',
+ issues: [
+ {
+ path: 'endpoints.bedrock.inferenceProfiles.us.anthropic.claude-sonnet-4-6',
+ segments: [
+ 'endpoints',
+ 'bedrock',
+ 'inferenceProfiles',
+ 'us.anthropic.claude-sonnet-4-6',
+ ],
+ code: 'too_small',
+ params: { minimum: 20, inclusive: true, exact: false, type: 'string' },
+ },
+ ],
+ },
+ }
+ : undefined,
+ );
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'endpoints.bedrock.inferenceProfiles', value: profiles },
+ ]);
+ expect(result.issues).toEqual([]);
+ expect(result.failed).toEqual([
+ {
+ path: 'endpoints.bedrock.inferenceProfiles',
+ code: 'too_small_length',
+ message: 'must be at least 20 characters',
+ params: { minimum: 20 },
+ },
+ ]);
+ });
+
+ it('maps an entry issue by segments to the entry name', async () => {
+ api.current!.reportMeta({ features: { dottedKeys: true } });
+ api.current!.setOverride((request) =>
+ request.method === 'PATCH'
+ ? {
+ status: 400,
+ body: {
+ code: 'CONFIG_OVERRIDE_INVALID',
+ issues: [
+ {
+ path: 'endpoints.custom.0.headers.x-portkey.config',
+ segments: ['endpoints', 'custom', '0', 'headers', 'x-portkey.config'],
+ code: 'invalid_type',
+ },
+ ],
+ },
+ }
+ : undefined,
+ );
+ const gpt = customEntries[1];
+ const result = await applyConfigChanges(base, [
+ upsert('GPT', 'GPT', { ...gpt, headers: { 'x-portkey.config': 'x' } }, gpt),
+ ]);
+ expect(result.failed).toEqual([
+ expect.objectContaining({ entryIdentity: 'GPT', entryPath: 'headers' }),
+ ]);
+ });
+
+ it("words a refused write by the admin API's code", async () => {
+ api.current!.setOverride((request) =>
+ request.method === 'PATCH'
+ ? {
+ status: 400,
+ body: {
+ error: 'Process-backed MCP servers are configured in librechat.yaml',
+ code: 'PROCESS_MCP_SERVER_OVERRIDE',
+ },
+ }
+ : undefined,
+ );
+ const result = await applyConfigChanges(base, [
+ { op: 'set', path: 'mcpServers.cloudflare.title', value: 'Cloudflare' },
+ ]);
+ expect(result.failed).toEqual([
+ {
+ path: 'mcpServers.cloudflare.title',
+ code: 'WRITE_FAILED',
+ message:
+ 'This MCP server runs a local process defined in librechat.yaml, so LibreChat applies no overrides to it.',
+ },
+ ]);
+ });
+});
diff --git a/src/server/config/save.ts b/src/server/config/save.ts
index 7f0bcd79..c6c7122c 100644
--- a/src/server/config/save.ts
+++ b/src/server/config/save.ts
@@ -6,16 +6,22 @@ import type * as t from '@/types';
import {
verifyWrite,
getEntryKey,
+ reportedPath,
+ uniqueSkipped,
isConfigRecord,
getValueAtPath,
+ readWriteReport,
+ createPathIndex,
+ describeApiError,
planConfigChanges,
+ describeIssueCode,
getKeyedCollection,
getWriteSkipReason,
- describeIssueCode,
- paramlessIssueCode,
+ classifyReportedIssue,
stripSecretPreviewValues,
} from '@/utils';
import {
+ getWriteRules,
getConfigSection,
MERGED_BACK_REASONS,
TERMS_NOT_ACCEPTED_CODE,
@@ -110,7 +116,11 @@ async function readWriteSnapshot(target: t.WriteTargetRef): Promise {
+async function authorizeWrite(
+ target: t.WriteTargetRef,
+ ops: t.ConfigChange[],
+ rules: t.ConfigWriteRules,
+): Promise {
if (target.type === 'scope') {
await requireAnyCapability([
SystemCapabilities.ASSIGN_CONFIGS,
@@ -121,7 +131,7 @@ async function authorizeWrite(target: t.WriteTargetRef, ops: t.ConfigChange[]):
const sections = [
...new Set(
ops
- .filter((op) => !getWriteSkipReason(op.path, op.op === 'reset'))
+ .filter((op) => !getWriteSkipReason(op.path, op.op === 'reset', rules))
.map((op) => getConfigSection(op.path)),
),
];
@@ -147,35 +157,59 @@ function sanitizeOps(ops: t.ConfigChange[]): t.ConfigChange[] {
});
}
+/** Whether `segments` start with `prefix`. */
+function startsWith(segments: readonly string[], prefix: readonly string[]): boolean {
+ return prefix.length <= segments.length && prefix.every((segment, i) => segments[i] === segment);
+}
+
/**
- * 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. The admin API reports codes without their
- * parameters, so a code whose message needs one (`too_small`'s minimum) is
- * worded without it.
+ * A rejected value the admin API reported. Its location is read key by key
+ * where the server reports `segments` (`dottedKeys`), so a record key that
+ * holds a dot stays one key; the issue shows on that key's record. An index
+ * inside a keyed collection is mapped to the identity of that sent entry,
+ * since it indexes the array that was sent. With `issueParams` the issue's
+ * bounds and options word it ("must be at least 1"); without them 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(paramlessIssueCode(code), undefined, 'is not a valid value');
- const sent = batch.find(
- ({ fieldPath }) => path === fieldPath || path.startsWith(`${fieldPath}.`),
- );
+function attributeBackendIssue(
+ issue: t.ReportedIssue,
+ batch: t.SaveEntry[],
+ features: t.ConfigFeatures,
+): t.ConfigIssue {
+ const segments =
+ features.dottedKeys && Array.isArray(issue.segments)
+ ? issue.segments
+ : (issue.path ?? '').split('.');
+ const path = reportedPath({ path: issue.path, segments });
+ const params = features.issueParams ? issue.params : undefined;
+ const described = classifyReportedIssue(issue.code, params);
+ const message = describeIssueCode(described.code, described.params, 'is not a valid value');
+ const reported: t.ConfigIssue = {
+ path,
+ code: params ? described.code : issue.code,
+ message,
+ ...(described.params && { params: described.params }),
+ };
+ const sent = batch.find(({ fieldPath }) => startsWith(segments, fieldPath.split('.')));
const collection = sent ? getKeyedCollection(sent.fieldPath) : undefined;
- if (!sent || !collection || !Array.isArray(sent.value) || path === sent.fieldPath) {
- return { path, code, message };
+ const depth = sent ? sent.fieldPath.split('.').length : 0;
+ if (!sent || !collection || !Array.isArray(sent.value) || segments.length === depth) {
+ return reported;
}
- const [index, ...rest] = path.slice(sent.fieldPath.length + 1).split('.');
+ const [index, ...rest] = segments.slice(depth);
const identity = getEntryKey(sent.value[Number(index)], collection.key);
- return { path, code, message, entryIdentity: identity, entryPath: rest.join('.') };
+ return { ...reported, entryIdentity: identity, entryPath: reportedPath({ segments: rest }) };
}
async function failureIssues(
response: Response,
path: string,
+ features: t.ConfigFeatures,
batch: t.SaveEntry[] = [],
): Promise {
- const body = await readJson(response);
+ const body = await readJson(response);
if (Array.isArray(body.issues) && body.issues.length > 0) {
- return body.issues.map((issue) => attributeBackendIssue(issue.path, issue.code, batch));
+ return body.issues.map((issue) => attributeBackendIssue(issue, batch, features));
}
if (body.code === TERMS_NOT_ACCEPTED_CODE) {
return [
@@ -188,23 +222,51 @@ async function failureIssues(
];
}
const message =
- body.error ?? body.message ?? `The admin API rejected the change (${response.status})`;
+ describeApiError(body) ??
+ body.error ??
+ body.message ??
+ `The admin API rejected the change (${response.status})`;
return [{ path, code: 'WRITE_FAILED', message }];
}
interface WriteOutcome {
writes: t.AppliedWrite[];
+ /** Writes that did not take effect: they are not counted as applied. */
dropped: t.SkippedChange[];
+ /** Parts of an applied write the server reported it did not store, or stores without effect. */
+ reported: t.SkippedChange[];
failure?: t.ConfigIssue[];
}
+/**
+ * Files what a write's reply reports (`readWriteReport`, for the locations
+ * the save changes): a location the write addressed as a whole is `dropped`,
+ * a part of it only `reported`. Before `writeReport`, a PATCH's dropped
+ * entries are read from the stored sections (`findDroppedEntries`).
+ */
+function fileWriteReport(
+ outcome: WriteOutcome,
+ body: t.PatchFieldsResponse,
+ written: readonly string[],
+ features: t.ConfigFeatures,
+ changed: t.PathIndex,
+): void {
+ const whole = new Set(written);
+ for (const item of readWriteReport(body, features, changed)) {
+ (whole.has(item.fieldPath) ? outcome.dropped : outcome.reported).push(item);
+ }
+}
+
/** 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,
+ contract: t.ConfigContract,
+ changed: t.PathIndex,
): Promise {
+ const { features } = contract;
+ const maxEntries = contract.limits.maxPatchEntries;
const priority = target.type === 'base' ? { priority: 0 } : {};
for (let i = 0; i < entries.length; i += maxEntries) {
const batch = entries.slice(i, i + maxEntries);
@@ -213,19 +275,21 @@ async function patchFields(
body: JSON.stringify({ entries: batch, ...priority }),
});
if (!response.ok) {
- outcome.failure = await failureIssues(response, batch[0].fieldPath, batch);
+ outcome.failure = await failureIssues(response, batch[0].fieldPath, features, batch);
return false;
}
const body = await readJson(response);
- outcome.writes.push(
- ...batch.map(({ fieldPath }) => ({ kind: 'set' as const, path: fieldPath })),
- );
- outcome.dropped.push(
- ...findDroppedEntries(batch, body).map((fieldPath) => ({
- fieldPath,
- reason: 'notStored' as const,
- })),
- );
+ const paths = batch.map(({ fieldPath }) => fieldPath);
+ outcome.writes.push(...paths.map((path) => ({ kind: 'set' as const, path })));
+ if (!features.writeReport) {
+ outcome.dropped.push(
+ ...findDroppedEntries(batch, body).map((fieldPath) => ({
+ fieldPath,
+ reason: 'notStored' as const,
+ })),
+ );
+ }
+ fileWriteReport(outcome, body, paths, features, changed);
}
return true;
}
@@ -251,14 +315,20 @@ async function writeFieldOps(
kind: Exclude,
paths: string[],
outcome: WriteOutcome,
+ features: t.ConfigFeatures,
+ changed: t.PathIndex,
): Promise {
for (const path of paths) {
const response = await fieldRequest(target, kind, path);
if (!response.ok && !(response.status === 404 && kind !== 'tombstone')) {
- outcome.failure = await failureIssues(response, path);
+ outcome.failure = await failureIssues(response, path, features);
return false;
}
outcome.writes.push({ kind, path });
+ if (response.ok) {
+ const body = await readJson(response);
+ fileWriteReport(outcome, body, [path], features, changed);
+ }
}
return true;
}
@@ -277,12 +347,14 @@ async function executePlan(
plan: t.WritePlan,
contract: t.ConfigContract,
): Promise {
- const outcome: WriteOutcome = { writes: [], dropped: [] };
+ const outcome: WriteOutcome = { writes: [], dropped: [], reported: [] };
+ const { features } = contract;
+ const changed = createPathIndex(plan.changed);
const steps = [
- () => writeFieldOps(target, 'tombstone', plan.tombstones, outcome),
- () => patchFields(target, plan.sets, outcome, contract.limits.maxPatchEntries),
- () => writeFieldOps(target, 'reset', plan.resets, outcome),
- () => writeFieldOps(target, 'untombstone', plan.untombstones, outcome),
+ () => writeFieldOps(target, 'tombstone', plan.tombstones, outcome, features, changed),
+ () => patchFields(target, plan.sets, outcome, contract, changed),
+ () => writeFieldOps(target, 'reset', plan.resets, outcome, features, changed),
+ () => writeFieldOps(target, 'untombstone', plan.untombstones, outcome, features, changed),
];
try {
for (const step of steps) {
@@ -392,15 +464,18 @@ export async function applyConfigChanges(
target: t.WriteTargetRef,
ops: t.ConfigChange[],
): Promise {
- await authorizeWrite(target, ops);
- const [snapshot, contract] = await Promise.all([readWriteSnapshot(target), readConfigContract()]);
- const plan = planConfigChanges(sanitizeOps(ops), snapshot, contract.features);
+ const contract = await readConfigContract();
+ const rules = getWriteRules(contract);
+ await authorizeWrite(target, ops, rules);
+ const snapshot = await readWriteSnapshot(target);
+ const plan = planConfigChanges(sanitizeOps(ops), snapshot, contract.features, rules);
const result: t.ApplyConfigChangesResult = {
applied: 0,
failed: [],
warnings: plan.warnings,
issues: plan.issues,
skipped: plan.skipped,
+ reported: [],
};
if (plan.issues.length > 0 || !hasWrites(plan)) return result;
const outcome = await executePlan(target, plan, contract);
@@ -408,7 +483,14 @@ export async function applyConfigChanges(
const compensated = await compensate(target, outcome.writes, snapshot.stored);
return { ...result, failed: outcome.failure, compensated };
}
- const notEffective = [...outcome.dropped, ...(await verifyTarget(target, plan, snapshot))];
+ const reportedPaths = new Set(
+ [...outcome.dropped, ...outcome.reported].map((item) => item.fieldPath),
+ );
+ /** What the server reported is worded by its reasons; a read-back finding at the same path is the same change. */
+ const verified = (await verifyTarget(target, plan, snapshot)).filter(
+ (item) => !reportedPaths.has(item.fieldPath),
+ );
+ const notEffective = [...outcome.dropped, ...verified];
const lost = new Set(
notEffective
.filter((item) => !MERGED_BACK_REASONS.has(item.reason))
@@ -418,6 +500,7 @@ export async function applyConfigChanges(
...result,
applied: Math.max(0, plan.effective - lost.size),
skipped: [...plan.skipped, ...notEffective],
+ reported: uniqueSkipped(outcome.reported),
};
}
diff --git a/src/server/scopes/read.test.ts b/src/server/scopes/read.test.ts
index 24a42f7e..dcfa0264 100644
--- a/src/server/scopes/read.test.ts
+++ b/src/server/scopes/read.test.ts
@@ -1,5 +1,12 @@
import { PrincipalType } from 'librechat-data-provider';
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type * as t from '@/types';
+import { describeApiError } from '@/utils';
+
+/** The API client's error wording, without its session layer. */
+function apiErrorBodyMessage(body: t.ApiErrorBody, status: number, fallback: string): string {
+ return describeApiError(body) ?? body.error ?? body.message ?? `${fallback}: ${status}`;
+}
interface FakeResponse {
ok: boolean;
@@ -20,7 +27,10 @@ function reply(status: number, body: object): FakeResponse {
return { ok: status >= 200 && status < 300, status, json: async () => body };
}
-vi.mock('../utils/api', () => ({
+vi.mock('../utils/api', async () => ({
+ apiErrorMessage: async (response: Response, fallback: string) =>
+ apiErrorBodyMessage(await response.json(), response.status, fallback),
+ apiErrorBodyMessage,
apiFetch: vi.fn(async (url: string, init?: { method?: string; body?: string }) => {
const call = { url, method: init?.method ?? 'GET', body: init?.body };
calls.push(call);
@@ -45,7 +55,7 @@ vi.mock('@tanstack/react-start', () => ({
}),
}));
-import { createScopeFn, getAvailableScopesFn } from './read';
+import { createScopeFn, setScopePriorityFn, getAvailableScopesFn } from './read';
const BOB = '6ac3e10890468262c2267093';
const ENG = '6ac3e10d90468262c22670b0';
@@ -238,3 +248,115 @@ describe('createScopeFn', () => {
expect(scope).toMatchObject({ _id: 'c-eng', priority: 20, isActive: false });
});
});
+
+describe('createScopeFn with expectedRevision', () => {
+ const created = (call: FakeCall): [number, object] => [
+ 201,
+ {
+ config: {
+ _id: 'new',
+ principalType: 'role',
+ principalId: 'EDITORS',
+ priority: JSON.parse(call.body ?? '{}').priority,
+ isActive: true,
+ overrides: {},
+ },
+ },
+ ];
+ const meta = (features: Record) =>
+ routes.push([/^GET \/api\/admin\/config\/meta$/, () => [200, { features }]]);
+
+ it('creates the profile with one create-only PUT, without reading it or the role first', async () => {
+ meta({ expectedRevision: true, principalCheck: true });
+ routes.push([/^PUT \/api\/admin\/config\/role\/EDITORS$/, created]);
+ const { scope } = await createScopeFn({
+ data: { principalType: PrincipalType.ROLE, principalId: 'EDITORS', name: 'EDITORS' },
+ });
+ expect(scope).toMatchObject({ _id: 'new', priority: 10 });
+ expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
+ 'GET /api/admin/config/meta',
+ 'PUT /api/admin/config/role/EDITORS',
+ ]);
+ expect(JSON.parse(calls[1].body ?? '{}')).toEqual({
+ overrides: {},
+ priority: 10,
+ expectedRevision: 'absent',
+ });
+ });
+
+ it('opens the profile someone created meanwhile instead of replacing it (409)', async () => {
+ meta({ expectedRevision: true, principalCheck: true });
+ routes.push([
+ /^PUT /,
+ () => [409, { code: 'CONFIG_VERSION_CONFLICT', currentRevision: 'c-eng:3' }],
+ ]);
+ routes.push([/^GET \/api\/admin\/config\/group\//, () => [200, { config: configs[2] }]]);
+ const { scope } = await createScopeFn({
+ data: { principalType: PrincipalType.GROUP, principalId: ENG, name: 'Engineering' },
+ });
+ expect(scope).toMatchObject({ _id: 'c-eng', priority: 20 });
+ expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
+ });
+
+ it('says the role does not exist when the server refuses the principal', async () => {
+ meta({ expectedRevision: true, principalCheck: true });
+ routes.push([
+ /^PUT /,
+ () => [404, { error: 'Principal not found', code: 'PRINCIPAL_NOT_FOUND' }],
+ ]);
+ await expect(
+ createScopeFn({
+ data: { principalType: PrincipalType.ROLE, principalId: 'GONE', name: 'GONE' },
+ }),
+ ).rejects.toThrow('There is no role named "GONE"');
+ await expect(
+ createScopeFn({ data: { principalType: PrincipalType.GROUP, principalId: ENG, name: 'E' } }),
+ ).rejects.toThrow(/doesn't exist/);
+ });
+
+ it('still looks the role up first on a server without the principal check', async () => {
+ meta({ expectedRevision: true });
+ await expect(
+ createScopeFn({
+ data: { principalType: PrincipalType.ROLE, principalId: 'NO_SUCH_ROLE', name: 'X' },
+ }),
+ ).rejects.toThrow('There is no role named "NO_SUCH_ROLE"');
+ expect(calls.some((c) => c.method === 'PUT')).toBe(false);
+ });
+});
+
+describe('setScopePriorityFn', () => {
+ it('sets only the priority through the priority route', async () => {
+ routes.push([
+ /^PATCH \/api\/admin\/config\/group\/[^/]+\/priority$/,
+ (call) => [
+ 200,
+ { config: { ...configs[2], priority: JSON.parse(call.body ?? '{}').priority } },
+ ],
+ ]);
+ await expect(
+ setScopePriorityFn({
+ data: { principalType: PrincipalType.GROUP, principalId: ENG, priority: 15 },
+ }),
+ ).resolves.toEqual({ priority: 15 });
+ expect(calls).toEqual([
+ {
+ url: `/api/admin/config/group/${ENG}/priority`,
+ method: 'PATCH',
+ body: JSON.stringify({ priority: 15 }),
+ },
+ ]);
+ });
+
+ it("words the server's refusal", async () => {
+ routes.push([
+ /^PATCH /,
+ () => [400, { error: 'priority must be a non-negative number', code: 'INVALID_PRIORITY' }],
+ ]);
+ await expect(
+ setScopePriorityFn({
+ data: { principalType: PrincipalType.GROUP, principalId: ENG, priority: 15 },
+ }),
+ ).rejects.toThrow('The priority must be a number of 0 or more.');
+ });
+});
diff --git a/src/server/scopes/read.ts b/src/server/scopes/read.ts
index 49617787..d0d6b270 100644
--- a/src/server/scopes/read.ts
+++ b/src/server/scopes/read.ts
@@ -11,11 +11,12 @@ import type {
import type * as t from '@/types';
import { collectOverridePaths, scopeOverrideEntries, stripSecretPreviewValues } from '@/utils';
import { normalizeAppServiceKeys } from '../config/normalize';
-import { apiErrorMessage, apiFetch } from '../utils/api';
+import { apiErrorBodyMessage, apiErrorMessage, apiFetch } from '../utils/api';
import { BASE_CONFIG_PRINCIPAL_ID } from '../constants';
-import { requireAnyCapability } from '../capabilities';
-import { DEFAULT_SCOPE_PRIORITY } from '@/constants';
+import { requireCapability, requireAnyCapability } from '../capabilities';
+import { MAX_SCOPE_PRIORITY, DEFAULT_SCOPE_PRIORITY } from '@/constants';
import { getSchemaPathSet } from '../config/schema';
+import { readConfigContract } from '../config/meta';
import { deepGet } from './paths';
/** The admin list endpoints return at most this many items per page. */
@@ -238,10 +239,96 @@ export const fieldProfileValuesOptions = (fieldPath: string) =>
queryFn: () => getFieldProfileValuesFn({ data: { fieldPath } }).then((r) => r.values),
});
+/** `expectedRevision` that makes a PUT create the profile only when the principal has none. */
+const ABSENT_REVISION = 'absent';
+
+/** The admin API's codes for a create-only PUT the principal already has a profile for, or has none. */
+const VERSION_CONFLICT_CODE = 'CONFIG_VERSION_CONFLICT';
+const PRINCIPAL_NOT_FOUND_CODE = 'PRINCIPAL_NOT_FOUND';
+
+function missingRoleMessage(principalId: string): string {
+ return `There is no role named "${principalId}"`;
+}
+
+/** A created or existing profile's document from a reply, or an error when it carries none. */
+async function readConfigDoc(response: Response): Promise {
+ const body = (await response.json()) as AdminConfigResponse | { message?: string };
+ const config = 'config' in body ? body.config : undefined;
+ if (!config?._id) {
+ throw new Error(
+ 'Backend did not return a config document. The scope may not have been created.',
+ );
+ }
+ return config;
+}
+
+/**
+ * Before `expectedRevision`: reads the principal's profile and returns it
+ * when there is one, else checks that a role exists (the admin API would
+ * create a profile for any name) and creates the profile with a PUT. A
+ * profile created by someone else between the read and the PUT is
+ * overwritten with an empty one.
+ */
+async function createScopeLegacy(
+ url: string,
+ data: t.CreateScopeInput,
+ principalId: string,
+ priority: number,
+): Promise {
+ const existing = await apiFetch(url);
+ if (existing.ok) return ((await existing.json()) as AdminConfigResponse).config;
+ if (data.principalType === PrincipalType.ROLE) {
+ const role = await apiFetch(`/api/admin/roles/${encodeURIComponent(principalId)}`);
+ if (role.status === 404) throw new Error(missingRoleMessage(principalId));
+ }
+ const response = await apiFetch(url, {
+ method: 'PUT',
+ body: JSON.stringify({ overrides: {}, priority }),
+ });
+ if (!response.ok) throw new Error(await apiErrorMessage(response, 'Failed to create scope'));
+ return readConfigDoc(response);
+}
+
+/**
+ * With `expectedRevision`: one create-only PUT (`expectedRevision: 'absent'`),
+ * so a profile created meanwhile is never overwritten; when one exists
+ * (409) it is read and returned. With `principalCheck` the admin API refuses
+ * a principal that doesn't exist (404 `PRINCIPAL_NOT_FOUND`), so the role
+ * is not looked up first.
+ */
+async function createScopeOnce(
+ url: string,
+ data: t.CreateScopeInput,
+ principalId: string,
+ priority: number,
+ principalCheck: boolean,
+): Promise {
+ if (!principalCheck && data.principalType === PrincipalType.ROLE) {
+ const role = await apiFetch(`/api/admin/roles/${encodeURIComponent(principalId)}`);
+ if (role.status === 404) throw new Error(missingRoleMessage(principalId));
+ }
+ const response = await apiFetch(url, {
+ method: 'PUT',
+ body: JSON.stringify({ overrides: {}, priority, expectedRevision: ABSENT_REVISION }),
+ });
+ if (response.ok) return readConfigDoc(response);
+ const body = (await response.json().catch(() => ({}))) as t.ApiErrorBody;
+ if (response.status === 409 && body.code === VERSION_CONFLICT_CODE) {
+ const existing = await apiFetch(url);
+ if (existing.ok) return ((await existing.json()) as AdminConfigResponse).config;
+ }
+ if (body.code === PRINCIPAL_NOT_FOUND_CODE && data.principalType === PrincipalType.ROLE) {
+ throw new Error(missingRoleMessage(principalId));
+ }
+ throw new Error(apiErrorBodyMessage(body, response.status, 'Failed to create scope'));
+}
+
/**
* Create a profile for a role, group or user. The priority defaults to
* `DEFAULT_SCOPE_PRIORITY` for the principal type. When the principal already
- * has a profile it is returned as is, so creating never replaces overrides.
+ * has a profile it is returned as is, so creating never replaces overrides:
+ * atomically on a server with `expectedRevision` (`createScopeOnce`), else
+ * by reading first (`createScopeLegacy`).
*/
export const createScopeFn = createServerFn({ method: 'POST' })
.inputValidator(
@@ -262,64 +349,62 @@ export const createScopeFn = createServerFn({ method: 'POST' })
path: ['principalId'],
}),
)
- .handler(
- async ({
- data,
- }: {
- data: {
- principalType: PrincipalType;
- name: string;
- priority?: number;
- principalId?: string;
- email?: string;
- };
- }) => {
- await requireAnyCapability([
- SystemCapabilities.ASSIGN_CONFIGS,
- SystemCapabilities.MANAGE_CONFIGS,
- ]);
- const principalId =
- data.principalId ??
- (data.principalType === PrincipalType.ROLE
- ? data.name.toLowerCase().replace(/\s+/g, '_')
- : `grp_${data.name.toLowerCase().replace(/\s+/g, '_')}`);
- const url = `/api/admin/config/${data.principalType}/${encodeURIComponent(principalId)}`;
- const toScope = (config: t.ScopeConfigDoc): t.ConfigScope => ({
+ .handler(async ({ data }: { data: t.CreateScopeInput }) => {
+ await requireAnyCapability([
+ SystemCapabilities.ASSIGN_CONFIGS,
+ SystemCapabilities.MANAGE_CONFIGS,
+ ]);
+ const principalId =
+ data.principalId ??
+ (data.principalType === PrincipalType.ROLE
+ ? data.name.toLowerCase().replace(/\s+/g, '_')
+ : `grp_${data.name.toLowerCase().replace(/\s+/g, '_')}`);
+ const url = `/api/admin/config/${data.principalType}/${encodeURIComponent(principalId)}`;
+ const priority =
+ data.priority ?? DEFAULT_SCOPE_PRIORITY[data.principalType as t.ScopePrincipalType];
+ const { features } = await readConfigContract();
+ const config = features.expectedRevision
+ ? await createScopeOnce(url, data, principalId, priority, features.principalCheck)
+ : await createScopeLegacy(url, data, principalId, priority);
+ return {
+ scope: {
...apiConfigToScope(config),
name: data.name,
...(data.email && { email: data.email }),
...(data.principalType === PrincipalType.GROUP && { memberCount: 0 }),
- });
-
- const existing = await apiFetch(url);
- if (existing.ok) {
- const { config } = (await existing.json()) as AdminConfigResponse;
- return { scope: toScope(config) };
- }
- if (data.principalType === PrincipalType.ROLE) {
- const role = await apiFetch(`/api/admin/roles/${encodeURIComponent(principalId)}`);
- if (role.status === 404) throw new Error(`There is no role named "${principalId}"`);
- }
-
- const priority =
- data.priority ?? DEFAULT_SCOPE_PRIORITY[data.principalType as t.ScopePrincipalType];
- const response = await apiFetch(url, {
- method: 'PUT',
- body: JSON.stringify({ overrides: {}, priority }),
- });
+ },
+ };
+ });
+/**
+ * Sets only a profile's priority (`priorityRoute`, D6): its overrides,
+ * hidden settings and active state stay as stored. Broad `manage:configs`
+ * only, like every priority change.
+ */
+export const setScopePriorityFn = createServerFn({ method: 'POST' })
+ .inputValidator(
+ z.object({
+ principalType: z.nativeEnum(PrincipalType),
+ principalId: z.string().min(1),
+ priority: z.number().int().min(0).max(MAX_SCOPE_PRIORITY),
+ }),
+ )
+ .handler(
+ async ({
+ data,
+ }: {
+ data: { principalType: PrincipalType; principalId: string; priority: number };
+ }) => {
+ await requireCapability(SystemCapabilities.MANAGE_CONFIGS);
+ const response = await apiFetch(
+ `/api/admin/config/${data.principalType}/${encodeURIComponent(data.principalId)}/priority`,
+ { method: 'PATCH', body: JSON.stringify({ priority: data.priority }) },
+ );
if (!response.ok) {
- throw new Error(await apiErrorMessage(response, 'Failed to create scope'));
- }
-
- const body = (await response.json()) as AdminConfigResponse | { message?: string };
- const config = 'config' in body ? body.config : undefined;
- if (!config?._id) {
- throw new Error(
- 'Backend did not return a config document. The scope may not have been created.',
- );
+ throw new Error(await apiErrorMessage(response, 'Failed to change the priority'));
}
- return { scope: toScope(config) };
+ const config = await readConfigDoc(response);
+ return { priority: config.priority };
},
);
diff --git a/src/server/utils/api.ts b/src/server/utils/api.ts
index 135e61ab..504bcd06 100644
--- a/src/server/utils/api.ts
+++ b/src/server/utils/api.ts
@@ -1,6 +1,8 @@
+import type * as t from '@/types';
import { ensureFreshBearer, refreshOn401 } from './refresh';
import { getApiBaseUrl, getServerApiUrl } from './url';
import { TERMS_NOT_ACCEPTED_CODE } from '@/constants';
+import { describeApiError } from '@/utils';
/** Skew window: refresh proactively when the bearer is within this of expiry. */
const PROACTIVE_REFRESH_SKEW_MS = 30_000;
@@ -46,19 +48,27 @@ export async function apiFetch(path: string, init?: RequestInit): Promise`, which the
- * panel explains (with where to accept them) instead of a bare 403.
+ * The message of a failed API response: the panel's wording of its stable
+ * `code` (`describeApiError`: a missing principal, a version conflict, a
+ * section the admin may not manage, …), else its `{ error }` or
+ * `{ message }`, else `fallback` with the status. LibreChat's refusal until
+ * the admin accepts its terms of service becomes `terms_not_accepted
+ * `, which the panel explains (with where to accept them)
+ * instead of a bare 403.
*/
export async function apiErrorMessage(response: Response, fallback: string): Promise {
- const body = (await response.json().catch(() => ({}))) as {
- error?: string;
- message?: string;
- code?: string;
- };
+ const body = (await response.json().catch(() => ({}))) as t.ApiErrorBody;
+ return apiErrorBodyMessage(body, response.status, fallback);
+}
+
+/** `apiErrorMessage` for a reply whose body was already read. */
+export function apiErrorBodyMessage(
+ body: t.ApiErrorBody,
+ status: number,
+ fallback: string,
+): string {
if (body.code === TERMS_NOT_ACCEPTED_CODE) return `${TERMS_NOT_ACCEPTED_CODE} ${getApiBaseUrl()}`;
- return body.error ?? body.message ?? `${fallback}: ${response.status}`;
+ return describeApiError(body) ?? body.error ?? body.message ?? `${fallback}: ${status}`;
}
/** Throws the message of a failed API response (see `apiErrorMessage`). */
diff --git a/src/test/admin.ts b/src/test/admin.ts
index 38388135..a3270b84 100644
--- a/src/test/admin.ts
+++ b/src/test/admin.ts
@@ -19,6 +19,9 @@ interface FakeDoc {
tombstones: string[];
}
+/** What a write's reply adds to the stored document (a write report, ignored credentials). */
+export type FakeWriteReport = (request: FakeAdminRequest) => Partial;
+
/** Replies the test wants instead of the default behaviour (return undefined to fall through). */
export type FakeAdminOverride = (
request: FakeAdminRequest,
@@ -57,6 +60,7 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
const requests: FakeAdminRequest[] = [];
const strippedSections = new Set();
let override: FakeAdminOverride | undefined;
+ let report: FakeWriteReport | undefined;
let meta: t.AdminConfigMetaBody | undefined;
const docKey = (principalType: string, principalId: string) =>
@@ -65,6 +69,10 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
const reply = (status: number, body: unknown) =>
({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response;
+ /** A successful write's reply, with what the test's write report adds. */
+ const written = (request: FakeAdminRequest, body: object) =>
+ reply(200, { ...body, ...report?.(request) });
+
function encryptSecrets(value: t.ConfigValue, existing: t.ConfigValue): t.ConfigValue {
if (Array.isArray(value)) {
const previous = Array.isArray(existing) ? existing : [];
@@ -135,7 +143,7 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
overrides: removeValueAtPath(current.overrides, fieldPath),
tombstones: [...new Set([...current.tombstones, fieldPath])],
});
- return reply(200, { config: docs.get(key) });
+ return written(request, { config: docs.get(key) });
}
if (method === 'DELETE') {
if (!doc) return reply(404, { error: 'Config not found' });
@@ -144,24 +152,25 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
overrides: removeValueAtPath(doc.overrides, fieldPath),
tombstones: doc.tombstones.filter((p) => p !== fieldPath && !p.startsWith(`${fieldPath}.`)),
});
- return reply(200, { config: redact(docs.get(key)!.overrides) });
+ return written(request, { config: redact(docs.get(key)!.overrides) });
}
let overrides = current.overrides;
const entries = (body?.entries ?? []).filter(
(entry: t.SaveEntry) => !strippedSections.has(entry.fieldPath.split('.')[0]),
);
- if (entries.length === 0)
- return reply(200, { message: 'No actionable field entries provided' });
+ if (entries.length === 0) {
+ return written(request, { message: 'No actionable field entries provided' });
+ }
for (const entry of entries) {
const existing = getValueAtPath(current.overrides, entry.fieldPath);
overrides = setValueAtPath(overrides, entry.fieldPath, encryptSecrets(entry.value, existing));
}
- const written = (body?.entries ?? []).map((e: t.SaveEntry) => e.fieldPath);
+ const paths = (body?.entries ?? []).map((e: t.SaveEntry) => e.fieldPath);
docs.set(key, {
overrides,
- tombstones: current.tombstones.filter((p) => !written.includes(p)),
+ tombstones: current.tombstones.filter((p) => !paths.includes(p)),
});
- return reply(200, { config: { overrides: redact(overrides) } });
+ return written(request, { config: { overrides: redact(overrides) } });
}
return {
@@ -177,6 +186,10 @@ export function createFakeAdminApi(yaml: t.ConfigRecord) {
setOverride: (next?: FakeAdminOverride) => {
override = next;
},
+ /** Adds a write report (`applied`, `skipped`, `ignored`) to each successful write's reply. */
+ reportWrites: (next?: FakeWriteReport) => {
+ report = 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;
diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts
index 09ff5a53..ff1b0cb2 100644
--- a/src/test/fixtures.ts
+++ b/src/test/fixtures.ts
@@ -1,4 +1,5 @@
import type * as t from '@/types';
+import { BUNDLED_CONFIG_CONTRACT } from '@/constants';
export function createField(overrides: Partial & { key: string }): t.SchemaField {
return {
@@ -12,3 +13,19 @@ export function createField(overrides: Partial & { key: string })
...overrides,
};
}
+
+/**
+ * The contract of a server that reports `GET /api/admin/config/meta`, with
+ * the given features on and rules changed; every other part is the bundled one.
+ */
+export function createServerContract(
+ features: Partial = {},
+ rules: Partial = {},
+): t.ConfigContract {
+ return {
+ source: 'server',
+ features: { ...BUNDLED_CONFIG_CONTRACT.features, ...features },
+ limits: BUNDLED_CONFIG_CONTRACT.limits,
+ rules: { ...BUNDLED_CONFIG_CONTRACT.rules, ...rules },
+ };
+}
diff --git a/src/test/page.tsx b/src/test/page.tsx
index 5115c9cd..5901f7a9 100644
--- a/src/test/page.tsx
+++ b/src/test/page.tsx
@@ -16,6 +16,8 @@ import { extractSchemaTree, extractSchemaDefaults } from '@/server/config';
import { CollectionEditsContext } from '@/contexts/CollectionEditsContext';
import { buildConfigChanges } from '@/components/configuration/edits';
import { collectFieldPaths } from '@/components/configuration/counts';
+import { BUNDLED_CONFIG_CONTRACT, getWriteRules } from '@/constants';
+import { ConfigContractContext } from '@/contexts/ContractContext';
import { ConfigTabContent } from '@/components/configuration';
import { ExpandContext } from '@/contexts/ExpandContext';
@@ -54,6 +56,8 @@ export interface TabHarnessOptions {
overrides?: t.ConfigRecord;
/** Press "Expand all" once rendered: every collapsible opens, lazily mounted ones too. */
expandAll?: boolean;
+ /** The connected server's admin config contract; the bundled one (an older server) by default. */
+ contract?: t.ConfigContract;
}
export interface TabHarness {
@@ -68,7 +72,7 @@ interface HarnessProps extends Required> {
state: { edits: t.ConfigEditsState | null; plan: (() => t.WritePlan) | null };
}
-function TabPage({ tab, tree, yaml, overrides, expandAll, state }: HarnessProps) {
+function TabPage({ tab, tree, yaml, overrides, expandAll, contract, state }: HarnessProps) {
const schemaPathSet = useMemo(() => new Set(collectFieldPaths(tree)), [tree]);
const config = useMemo(() => mergeScopeLayer(yaml, overrides), [yaml, overrides]);
const layers = useMemo(
@@ -130,13 +134,14 @@ function TabPage({ tab, tree, yaml, overrides, expandAll, state }: HarnessProps)
root: config,
collections: edits.collectionEdits,
visibility: edits.visibilityEdits,
+ writeRules: getWriteRules(contract),
});
- return planConfigChanges(changes.ops, {
- stored: overrides,
- tombstones: [],
- lower: yaml,
- lowerKind: 'yaml',
- });
+ return planConfigChanges(
+ changes.ops,
+ { stored: overrides, tombstones: [], lower: yaml, lowerKind: 'yaml' },
+ contract.features,
+ getWriteRules(contract),
+ );
};
return (
@@ -181,17 +186,21 @@ export function renderTab({
yaml = {},
overrides = {},
expandAll = false,
+ contract = BUNDLED_CONFIG_CONTRACT,
}: TabHarnessOptions): TabHarness & ReturnType {
const state: HarnessProps['state'] = { edits: null, plan: null };
const result = render(
- ,
+
+
+ ,
);
return {
...result,
diff --git a/src/test/sections.tsx b/src/test/sections.tsx
index 66b15b91..64a6d39a 100644
--- a/src/test/sections.tsx
+++ b/src/test/sections.tsx
@@ -15,7 +15,8 @@ import { extractSchemaTree, extractSchemaDefaults, getSchemaPathSet } from '@/se
import { SECTION_META } from '@/components/configuration/configMeta';
import { ConfigTabContent } from '@/components/configuration';
import { runtimeDefaultAt } from '@/hooks/config/layers';
-import { READ_ONLY_CONFIG_SECTIONS } from '@/constants';
+import { ConfigContractContext } from '@/contexts/ContractContext';
+import { BUNDLED_CONFIG_CONTRACT, getWriteRules } from '@/constants';
const TREE = extractSchemaTree(configSchema as t.ZodSchemaLike);
const SCHEMA_DEFAULTS = flattenObject(
@@ -27,13 +28,15 @@ const SCHEMA_PATHS = getSchemaPathSet();
export function tabSections(
keys: string[],
extraFields: Readonly> = {},
+ contract: t.ConfigContract = BUNDLED_CONFIG_CONTRACT,
): t.ConfigSectionConfig[] {
+ const { readOnlySections } = getWriteRules(contract);
return keys.flatMap((key) => {
const field = TREE.find((section) => section.key === key);
if (!field) return [];
const children = [...(field.children ?? []), ...(extraFields[key] ?? [])];
const structured = (field.isObject || field.type === 'record') && children.length > 0;
- const readOnlyReason = READ_ONLY_CONFIG_SECTIONS.get(key);
+ const readOnlyReason = readOnlySections.get(key);
return [
{
id: key,
@@ -63,10 +66,16 @@ export interface SectionsHarnessOptions {
showConfiguredOnly?: boolean;
/** Fields a newer schema adds to a section, by section key. */
extraFields?: Readonly>;
+ /** The server's admin config contract (the bundled one: an older server). */
+ contract?: t.ConfigContract;
}
/** Profile-mode (USER role) value layers over Base, built by the page's own code. */
-function profileLayers(base: t.ValueLayers, profile: t.ConfigRecord): t.ValueLayers {
+function profileLayers(
+ base: t.ValueLayers,
+ profile: t.ConfigRecord,
+ rules: t.ConfigWriteRules,
+): t.ValueLayers {
const paths = collectLeafPaths(profile);
return withScopeLayer(
base,
@@ -78,14 +87,22 @@ function profileLayers(base: t.ValueLayers, profile: t.ConfigRecord): t.ValueLay
tombstones: [],
},
SCHEMA_PATHS,
+ rules,
);
}
-/** Base-mode value layers from a librechat.yaml and base overrides, built by the page's own code. */
-export function baseLayers(yaml: t.ConfigRecord, overrides: t.ConfigRecord): t.ValueLayers {
+/**
+ * Base-mode value layers from a librechat.yaml and base overrides, built by
+ * the page's own code under a server's write `rules` (the bundled ones by default).
+ */
+export function baseLayers(
+ yaml: t.ConfigRecord,
+ overrides: t.ConfigRecord,
+ rules?: t.ConfigWriteRules,
+): t.ValueLayers {
return createBaseLayers(
{
- config: mergeScopeLayer(yaml, overrides),
+ config: mergeScopeLayer(yaml, overrides, [], rules),
yamlConfig: yaml,
yamlPaths: collectLeafPaths(yaml),
dbOverrides: overrides,
@@ -108,9 +125,11 @@ export function SectionsHarness({
profile,
showConfiguredOnly,
extraFields,
+ contract = BUNDLED_CONFIG_CONTRACT,
}: SectionsHarnessOptions) {
- const base = baseLayers(yaml, overrides);
- const layers = profile ? profileLayers(base, profile) : base;
+ const rules = getWriteRules(contract);
+ const base = baseLayers(yaml, overrides, rules);
+ const layers = profile ? profileLayers(base, profile, rules) : base;
const [edited, setEdited] = useState({});
const [values, setValues] = useState(layers.effective ?? {});
const configured = new Set(
@@ -137,28 +156,30 @@ export function SectionsHarness({
sections.map((key) => [key, { canView: true, canEdit: true }]),
);
return (
- {
- onReset?.(path);
- stage(path, undefined);
- }}
- onDiscardField={discard}
- readOnly={readOnly}
- permissions={{ canView: true, canEdit: !readOnly }}
- sectionPermissions={permissions}
- configuredPaths={configured}
- dbOverridePaths={new Set(collectLeafPaths(overrides))}
- touchedPaths={new Set(Object.keys(edited))}
- pendingResets={new Set()}
- schemaDefaults={SCHEMA_DEFAULTS}
- valueLayers={layers}
- isEditingScope={profile != null}
- showConfiguredOnly={showConfiguredOnly}
- />
+
+ {
+ onReset?.(path);
+ stage(path, undefined);
+ }}
+ onDiscardField={discard}
+ readOnly={readOnly}
+ permissions={{ canView: true, canEdit: !readOnly }}
+ sectionPermissions={permissions}
+ configuredPaths={configured}
+ dbOverridePaths={new Set(collectLeafPaths(overrides))}
+ touchedPaths={new Set(Object.keys(edited))}
+ pendingResets={new Set()}
+ schemaDefaults={SCHEMA_DEFAULTS}
+ valueLayers={layers}
+ isEditingScope={profile != null}
+ showConfiguredOnly={showConfiguredOnly}
+ />
+
);
}
diff --git a/src/types/changes.ts b/src/types/changes.ts
index 66f8a069..4d329cb3 100644
--- a/src/types/changes.ts
+++ b/src/types/changes.ts
@@ -47,6 +47,50 @@ export interface ConfigIssue {
params?: Record;
}
+/** A location in a config document as the admin API reports it. */
+export interface ReportedLocation {
+ /** Absent where the report names the location by `segments` only (a `/changes` op). */
+ path?: string;
+ /** The same location key by key: unambiguous where a record key holds a dot (`dottedKeys`). */
+ segments?: string[];
+}
+
+/** Bounds and options of an issue the admin API reports (`issueParams`); never a submitted value. */
+export interface ReportedIssueParams {
+ expected?: string | number | boolean | null;
+ received?: string;
+ options?: Array;
+ minimum?: number;
+ maximum?: number;
+ inclusive?: boolean;
+ exact?: boolean;
+ type?: string;
+ validation?: string;
+ multipleOf?: number;
+ /** A refinement's stable reason (`missing_port`). */
+ reason?: string;
+}
+
+/** A rejected value the admin API reports: where, a stable code, and (`issueParams`) its bounds. */
+export interface ReportedIssue extends ReportedLocation {
+ code: string;
+ params?: ReportedIssueParams;
+}
+
+/** A location a write did not store (`skipped`) or stores without effect (`ignored`), and why. */
+export interface ReportedSkip extends ReportedLocation {
+ reason: string;
+}
+
+/** An admin API error reply: its text, and the stable `code` (and route details) newer servers add. */
+export interface ApiErrorBody {
+ error?: string;
+ message?: string;
+ code?: string;
+ /** `FORBIDDEN_SECTION`: the section the caller may not manage. */
+ section?: string;
+}
+
/** Input of `applyConfigChangesFn` (WS1). */
export interface ApplyConfigChangesRequest {
target: WriteTarget;
@@ -72,6 +116,8 @@ export type ChangeWarning =
| 'shadowed'
/** Sets a provider title setting while Global exists, so LibreChat ignores it (D11). */
| 'shadowedBlock'
+ /** Global sets the other half of the title endpoint and model, chosen together (`titleResolution: 'field'`). */
+ | 'shadowedPair'
/** Turns on required acceptance of the terms, which also gates this admin's own changes (FX-5). */
| 'requireTerms'
/** Empties the model spec list: no specs, and Specs only has no effect (MS-1). */
@@ -97,6 +143,11 @@ export interface ApplyConfigChangesResult {
issues: ConfigIssue[];
/** Changes not sent (read-only, permission, …), not stored, or without effect, and why. */
skipped: SkippedChange[];
+ /**
+ * Parts of written changes the server reported it did not store, or stores
+ * without effect (a credential it drops): the change itself is written.
+ */
+ reported: SkippedChange[];
/** A failed write was rolled back to the overrides captured before it. */
compensated?: boolean;
}
@@ -197,6 +248,12 @@ export interface WritePlan {
storedAfter: ConfigRecord;
/** Input changes that produce at least one write. */
effective: number;
+ /**
+ * Where the writes change the target: their paths, a keyed collection's by
+ * the entries the changes touch (`endpoints.custom.Moonshot`, `entryLocation`)
+ * rather than the whole list a write sends.
+ */
+ changed: string[];
}
/** The parts of a zod (v3) issue the save pipeline reads (WS1). */
diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts
index 66fae118..4a1954af 100644
--- a/src/types/config-ui.ts
+++ b/src/types/config-ui.ts
@@ -14,7 +14,8 @@ import type { TocEntry, JumpTarget, ConfigSearchHit, ConfigSearchItem } from './
import type { BaseOverridesState, ImportSkippedKey, ImportRequest } from './review';
import type { TabBadge, PathIndex, ValueLayers } from './values';
import type { FieldControlProps } from './controls';
-import type { VisibilityEdit } from './changes';
+import type { ReportedIssue, ReportedLocation, ReportedSkip, VisibilityEdit } from './changes';
+import type { ConfigWriteRules } from './contract';
export interface ConfigTab {
id: string;
@@ -528,6 +529,8 @@ export interface SaveContext {
hasOverride: (path: string) => boolean;
/** Whether the edit target is a profile, whose values beneath come from Base (not librechat.yaml). */
isScope?: boolean;
+ /** The server's write rules (`getWriteRules`); the bundled ones when absent. */
+ writeRules?: ConfigWriteRules;
}
/** Result of the server-side pre-flight that runs before any write. */
@@ -540,11 +543,17 @@ export interface ConfigCheckResult {
export interface PatchFieldsResponse {
error?: string;
message?: string;
- /** `CONFIG_OVERRIDE_INVALID` when the admin API rejected the values (see `issues`). */
+ /** `CONFIG_OVERRIDE_INVALID` when the admin API rejected the values (see `issues`), or why it refused the write. */
code?: string;
/** Paths (YAML keys, array indices of the sent value) and stable codes of rejected values. */
- issues?: Array<{ path: string; code: string }>;
+ issues?: ReportedIssue[];
config?: { overrides?: ConfigRecord; tombstones?: string[] } | null;
+ /** `writeReport`: the locations the write stored or removed. */
+ applied?: ReportedLocation[];
+ /** `writeReport`: the locations, or parts of a written value, the write did not store. */
+ skipped?: ReportedSkip[];
+ /** Stored values the runtime merge leaves out (`secret_rebinding`, …). */
+ ignored?: ReportedSkip[];
}
/** Outcome of a save call: how many entries the backend applied and which were dropped. */
diff --git a/src/types/config.ts b/src/types/config.ts
index ee4fbc50..f6559264 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -187,6 +187,9 @@ export type ReadOnlyReason = 'baseOnly' | 'yamlOnly';
* - `notApplied`: stored, but the effective configuration still shows the lower layer's value
* - `yamlMerged`: a key removed in the panel that LibreChat merges back from the layer beneath
* - `baseScope`: applies to everyone, so it can only be set in Base, not in a profile
+ * - `processServer`: inside an MCP server librechat.yaml runs as a local process (no overrides apply)
+ * - `secretRebinding`: a stored credential LibreChat leaves out, since its destination changed
+ * without the credential being entered again
*/
export type SkipReason =
| ReadOnlyReason
@@ -199,7 +202,9 @@ export type SkipReason =
| 'yamlMerged'
| 'baseScope'
| 'inheritedValue'
- | 'inheritedMerged';
+ | 'inheritedMerged'
+ | 'processServer'
+ | 'secretRebinding';
/** A record key the admin API cannot store, and the dot-path of the object holding it. */
export interface UnsafeConfigKey {
diff --git a/src/types/contract.ts b/src/types/contract.ts
index 1be7a882..41949bdc 100644
--- a/src/types/contract.ts
+++ b/src/types/contract.ts
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
+import type { ReadOnlyReason } from './config';
/**
* A behaviour of the admin config API that a LibreChat server reports in
@@ -24,7 +25,9 @@ export type ConfigFeatureFlag =
| 'azureGroupSecrets'
| 'yamlSecretPreview'
| 'envAllowlist'
- | 'secretRebinding';
+ | 'secretRebinding'
+ | 'writeReport'
+ | 'issueParams';
/** Which admin config behaviours the server has; a flag it does not report is `false`. */
export type ConfigFeatures = Readonly>;
@@ -71,6 +74,13 @@ export interface ConfigInterfaceRules {
permissionSubKeys: string[];
}
+/**
+ * Why only librechat.yaml sets a path: LibreChat reads it once at startup
+ * (`startup`), it is a boundary only the operator sets (`operator`), or
+ * nothing reads it (`unused`).
+ */
+export type DeploymentOnlyReason = 'startup' | 'operator' | 'unused';
+
/** The write rules a server enforces, in `librechat.yaml` key names. */
export interface ConfigRules {
/** Top-level sections the server's config schema defines. */
@@ -81,6 +91,8 @@ export interface ConfigRules {
basePrincipalSections: string[];
/** Fields inside overridable sections that only `librechat.yaml` sets. */
baseOnlyPaths: string[];
+ /** Sections and fields only `librechat.yaml` sets, with why; writes to them are skipped. */
+ deploymentOnlyPaths: Readonly>;
/** Arrays merged item by item on a key field instead of replaced. */
arrayMergeKeys: Readonly>;
/** Objects an override replaces whole instead of merging into. */
@@ -119,6 +131,19 @@ export interface ConfigContract {
rules: ConfigRules;
}
+/**
+ * Where the panel may write, from a contract's rules and features: what it
+ * renders read-only and leaves out of saves (see `getWriteRules`).
+ */
+export interface ConfigWriteRules {
+ /** Top-level sections the panel never writes, with why. */
+ readOnlySections: ReadonlyMap;
+ /** Fields inside writable sections that only librechat.yaml sets. */
+ yamlManagedFields: ReadonlySet;
+ /** Of `yamlManagedFields`, those LibreChat reads only once, at startup. */
+ startupOnlyFields: ReadonlySet;
+}
+
/** `GET /api/admin/config/meta` as parsed: every part optional, since forks and older servers differ. */
export interface AdminConfigMetaBody {
versions?: Partial;
diff --git a/src/types/features.ts b/src/types/features.ts
index 5f8f6bfb..08b723f3 100644
--- a/src/types/features.ts
+++ b/src/types/features.ts
@@ -49,8 +49,11 @@ export interface WebSearchCredential {
required: boolean;
/** A key: typed into a masked input. */
secret: boolean;
- /** LibreChat stores it encrypted and returns a masked preview (`Preview`). */
- encrypted: boolean;
+ /**
+ * LibreChat stores it encrypted and returns a masked preview (`Preview`)
+ * only once it reports `webSearchStoredKeys` (Keenable); otherwise always, when `secret`.
+ */
+ encryptedSince?: 'webSearchStoredKeys';
/** The environment variable LibreChat reads when the key is not set (`SERPER_API_KEY`). */
env: string;
}
diff --git a/src/types/hooks.ts b/src/types/hooks.ts
index df326f81..b214f7b4 100644
--- a/src/types/hooks.ts
+++ b/src/types/hooks.ts
@@ -12,6 +12,7 @@ import type {
CollectionEdits,
ConfigWarning,
ChangeSummaryItem,
+ ApplyConfigChangesResult,
} from './changes';
import type {
ConfigValue,
@@ -220,13 +221,15 @@ export interface ConfigEditsState {
/**
* Writes saves and resets to a target through the validated, all-or-nothing
* apply; rejects with a readable summary when anything is invalid, and
- * resolves to the changes that were not stored or did not take effect.
+ * resolves to the changes that were not stored or did not take effect
+ * (`skipped`), apart from the parts of written ones the server reported
+ * (`reported`).
*/
export type WriteChangesFn = (
target: WriteTarget,
saves: SaveEntry[],
resets: string[],
-) => Promise;
+) => Promise>;
/** Toast for a finished write, listing anything that was not stored. */
export type AnnounceWriteFn = (
diff --git a/src/types/providers.ts b/src/types/providers.ts
index 7df993d9..c3d490b3 100644
--- a/src/types/providers.ts
+++ b/src/types/providers.ts
@@ -1,6 +1,7 @@
import type { ReactNode, RefObject } from 'react';
import type { ConfigValue, ConfigRecord, FlatConfigMap, SchemaField, SelectOption } from './config';
import type { FieldRendererProps } from './config-ui';
+import type { ConfigRules } from './contract';
import type { ShadowedBy } from './values';
/** One endpoint LibreChat serves to the signed-in admin (`GET /api/endpoints`). */
@@ -222,9 +223,10 @@ export interface SummaryReader {
/**
* How Global overrides shadow the value (D11): `wins` when Global sets the
* key, `ignored` when its title block exists without it (LibreChat's
- * default applies), else null.
+ * default applies), `paired` when it sets the other half of the title
+ * endpoint and model (`titleResolution: 'field'`), else null.
*/
- shadowOf?: (path: string) => 'wins' | 'ignored' | null;
+ shadowOf?: (path: string) => 'wins' | 'ignored' | 'paired' | null;
}
export interface SummaryReaderInput {
@@ -234,6 +236,8 @@ export interface SummaryReaderInput {
configuredPaths?: ReadonlySet;
editedValues?: FlatConfigMap;
root: ConfigValue;
+ /** How the server applies `endpoints.all` title settings (`block` when absent). */
+ titleResolution?: ConfigRules['titleResolution'];
}
export interface StaleOverrideProps {
diff --git a/src/types/scope.ts b/src/types/scope.ts
index 3e52affc..fcad6d57 100644
--- a/src/types/scope.ts
+++ b/src/types/scope.ts
@@ -70,8 +70,13 @@ export type ScopeGateReason = 'baseOnly' | 'yamlOnly' | 'tenantWide' | 'preAuth'
/** Another profile with the same priority that can apply to the same user. */
export interface ScopePriorityTie {
scope: ConfigScope;
- /** Paths both profiles set; when non-empty, users in both get an undefined value. */
+ /**
+ * Paths both profiles set. Where they overlap, users in both get the value
+ * of the profile that applies last: undefined before `deterministicPrecedence`.
+ */
sharedPaths: string[];
+ /** Whether this profile applies after the one it ties with, so its values win (`deterministicPrecedence`). */
+ appliesAfter: boolean;
}
/** Profile lookups shared by the selector, the context bar and the field cascade. */
@@ -114,6 +119,13 @@ export interface ScopeBarProps extends Pick<
/** Switches to Base through the page's unsaved-changes guard. */
onBackToBase: () => void;
onError?: (message: string) => void;
+ /** Whether the admin may change profile priorities (broad `manage:configs`). */
+ canEditPriority?: boolean;
+}
+
+export interface ScopePriorityEditorProps {
+ scope: ConfigScope;
+ onError?: (message: string) => void;
}
export interface ScopeNotFoundProps {
@@ -157,6 +169,10 @@ export interface ScopeCandidate {
description?: string;
}
+/** What `createScopeFn` creates a profile for: the principal, its name and, optionally, a priority. */
+export type CreateScopeInput = Pick &
+ Partial>;
+
/** The "Overridden by …" link on a settings row in Base, opening the field's cascade. */
export interface ScopeCascadeProps {
fieldPath: string;
diff --git a/src/types/specs.ts b/src/types/specs.ts
index 770384ea..28942a7e 100644
--- a/src/types/specs.ts
+++ b/src/types/specs.ts
@@ -186,6 +186,8 @@ export interface ChipPickerProps {
addLabel: string;
/** Text of a chip whose value matches no choice ("Foo (not configured)"). */
unknownLabel: (value: string) => string;
+ /** The choice a value names, where several spellings name one (`Ollama` is `ollama`). */
+ matchValue?: (value: string) => string;
'aria-label': string;
'aria-describedby'?: string;
}
diff --git a/src/types/values.ts b/src/types/values.ts
index 75ed5708..b1229cd5 100644
--- a/src/types/values.ts
+++ b/src/types/values.ts
@@ -3,6 +3,7 @@ import type { PrincipalType } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { ConfigValue, ConfigRecord, FlatConfigMap } from './config';
import type { VisibilityEdit } from './changes';
+import type { ConfigRules, ConfigWriteRules } from './contract';
/**
* Where a field's value comes from (WS4):
@@ -37,10 +38,17 @@ export interface FieldLayer {
/** A field whose own value is ignored because another path takes precedence (WS4, EV-8). */
export interface ShadowedBy {
+ /** The setting that wins: Global's own field, or for `pair` the other half, which Global sets. */
path: string;
+ /** What this field gets from Global (`undefined` where Global leaves it unset). */
value: ConfigValue;
- /** `block`: the whole other block wins, even where it leaves this field unset (titles); `field`: only a value it sets wins. */
- precedence?: 'block' | 'field';
+ /**
+ * `block`: the whole other block wins, even where it leaves this field unset
+ * (titles, `titleResolution: 'block'`); `field`: only a value it sets wins;
+ * `pair`: it sets the other half of a choice made together (the title
+ * endpoint and model, `titleResolution: 'field'`), so it wins here too.
+ */
+ precedence?: 'block' | 'field' | 'pair';
}
/** Everything a field row needs to render its value and state (WS4; `error` from WS1). */
@@ -132,6 +140,10 @@ export interface ConfigValuesData {
layers?: ValueLayers | null;
/** Pending profile Hide/Unhide by path: a row shows what Save will do. */
visibilityEdits?: Readonly>;
+ /** The server's write rules: which sections it reads only from librechat.yaml. */
+ writeRules?: ConfigWriteRules;
+ /** How the server applies `endpoints.all` title settings (`block` when absent). */
+ titleResolution?: ConfigRules['titleResolution'];
}
/** Props of `ConfigValuesProvider` (WS4). */
diff --git a/src/utils/changes.test.ts b/src/utils/changes.test.ts
index 45acf2fb..95b2c79c 100644
--- a/src/utils/changes.test.ts
+++ b/src/utils/changes.test.ts
@@ -145,6 +145,47 @@ describe('planConfigChanges', () => {
},
]);
expect(plan.effective).toBe(1);
+ expect(plan.changed).toEqual(['endpoints.custom.GPT']);
+ });
+
+ it('names what it changes by path, and a keyed collection by the entries its changes touch', () => {
+ const models = { default: ['m'] };
+ const adminA = { name: 'AdminA', apiKey: 'user_provided', baseURL: 'https://a', models };
+ const ollama = { ...adminA, name: 'Ollama', baseURL: 'http://ollama:11434/v1' };
+ const plan = planConfigChanges(
+ [
+ { op: 'set', path: 'mcpServers.github.url', value: 'https://mcp.example.com/' },
+ { op: 'reset', path: 'balance.refillAmount' },
+ { op: 'tombstone', path: 'endpoints.openAI.titleModel' },
+ {
+ op: 'upsertEntry',
+ path: 'endpoints.custom',
+ key: 'Ollama',
+ identity: null,
+ value: ollama,
+ },
+ {
+ op: 'upsertEntry',
+ path: 'endpoints.custom',
+ key: 'AdminB',
+ identity: 'AdminA',
+ value: { ...adminA, name: 'AdminB' },
+ },
+ ],
+ snapshot({
+ balance: { refillAmount: 5 },
+ endpoints: { custom: [adminA] },
+ }),
+ );
+ expect(plan.issues).toEqual([]);
+ expect(plan.changed).toEqual([
+ 'mcpServers.github.url',
+ 'endpoints.custom.ollama',
+ 'endpoints.custom.AdminB',
+ 'endpoints.custom.AdminA',
+ 'balance',
+ 'endpoints.openAI.titleModel',
+ ]);
});
it('blocks the whole batch on an invalid entry, naming the entry and field', () => {
diff --git a/src/utils/changes.ts b/src/utils/changes.ts
index dfb87a77..191826fa 100644
--- a/src/utils/changes.ts
+++ b/src/utils/changes.ts
@@ -7,6 +7,7 @@ import type * as t from '@/types';
import {
getEntryKey,
diffOverride,
+ entryLocation,
hasHiddenSecret,
mergeConfigLayers,
getKeyedCollection,
@@ -32,6 +33,7 @@ import {
getConfigSection,
getReadOnlyReason,
SECRET_CONFIG_KEYS,
+ BUNDLED_WRITE_RULES,
BUNDLED_CONFIG_FEATURES,
} from '@/constants';
import { isSecretKey, toSecretPreviewKey, valueOrSecretPreview } from './secrets';
@@ -61,8 +63,9 @@ export function findWriteRuleIssues(
path: string,
value: t.ConfigValue,
features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
): t.ConfigIssue[] {
- const issues = findValueRuleIssues(path, value, features);
+ const issues = findValueRuleIssues(path, value, features, rules);
const collection = getKeyedCollection(path);
if (!collection || !Array.isArray(value) || issues.length === 0) return issues;
return issues.map((issue) => withEntryOf(issue, collection, value));
@@ -84,8 +87,9 @@ function findValueRuleIssues(
path: string,
value: t.ConfigValue,
features: t.ConfigFeatures,
+ rules: t.ConfigWriteRules,
): t.ConfigIssue[] {
- const managed = findYamlManagedValues(path, value);
+ const managed = findYamlManagedValues(path, value, rules);
if (managed.length > 0) {
return managed.map((managedPath) => ({
path: managedPath,
@@ -139,6 +143,7 @@ function blankTextIssue({ path, key }: t.BlankConfigText): t.ConfigIssue {
interface PlanState {
snapshot: t.WriteSnapshot;
features: t.ConfigFeatures;
+ rules: t.ConfigWriteRules;
sets: Map;
resets: string[];
tombstones: string[];
@@ -267,8 +272,8 @@ function collectOps(state: PlanState, ops: t.ConfigChange[]): void {
for (const op of ops) {
const isRemoval = op.op === 'reset' || op.op === 'untombstone' || op.op === 'removeEntry';
const reason =
- getWriteSkipReason(op.path, isRemoval) ??
- (isProfile && !isRemoval ? scopeGateSkipReason(op.path) : undefined);
+ getWriteSkipReason(op.path, isRemoval, state.rules) ??
+ (isProfile && !isRemoval ? scopeGateSkipReason(op.path, state.rules) : undefined);
if (reason) {
state.skipped.push({ fieldPath: op.path, reason });
continue;
@@ -541,8 +546,8 @@ 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, state.features).filter((issue) =>
- isIntroducedIssue(state, path, issue),
+ const ruleIssues = findWriteRuleIssues(path, value, state.features, state.rules).filter(
+ (issue) => isIntroducedIssue(state, path, issue),
);
if (ruleIssues.length > 0) {
state.issues.push(...ruleIssues);
@@ -585,7 +590,9 @@ function validateSections(state: PlanState, blocked: ReadonlySet): void
];
/** LibreChat reads librechat.yaml-only sections from the file alone: removing an override there can't break them. */
const sections = new Set(
- written.filter((path) => getReadOnlyReason(path) !== 'yamlOnly').map(getConfigSection),
+ written
+ .filter((path) => getReadOnlyReason(path, state.rules) !== 'yamlOnly')
+ .map(getConfigSection),
);
const reported = new Set(state.issues.map(issueKey));
const { lower, stored } = state.snapshot;
@@ -636,10 +643,20 @@ function findMergedBackKeys(state: PlanState): void {
}
}
+/** The plan's write paths, a keyed collection's by the entries its changes touch. */
+function changedLocations(state: PlanState): string[] {
+ const paths = [...state.sets.keys(), ...state.resets, ...state.tombstones, ...state.untombstones];
+ return paths.flatMap((path) => {
+ const entries = state.touchedEntries.get(path);
+ return entries ? [...entries].map((name) => entryLocation(path, name)) : [path];
+ });
+}
+
/**
* Resolves a batch of changes against the target's stored overrides and the
* layer beneath it, into the admin API writes that implement it, by the
- * server's contract `features` (the bundled ones: an older server). Everything
+ * server's contract `features` and write `rules` (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
@@ -650,10 +667,12 @@ export function planConfigChanges(
ops: t.ConfigChange[],
snapshot: t.WriteSnapshot,
features: t.ConfigFeatures = BUNDLED_CONFIG_FEATURES,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
): t.WritePlan {
const state: PlanState = {
snapshot,
features,
+ rules,
sets: new Map(),
resets: [],
tombstones: [],
@@ -684,6 +703,7 @@ export function planConfigChanges(
warnings: state.warnings,
storedAfter,
effective: state.effective,
+ changed: changedLocations(state),
};
}
diff --git a/src/utils/collections.ts b/src/utils/collections.ts
index 697850a8..d07c7a33 100644
--- a/src/utils/collections.ts
+++ b/src/utils/collections.ts
@@ -13,6 +13,16 @@ export function getKeyedCollection(path: string): t.KeyedCollection | undefined
return KEYED_COLLECTIONS.get(path);
}
+/**
+ * The location of a named entry (`mcpServers.github`, `endpoints.custom.Moonshot`),
+ * by the name LibreChat serves a keyed collection's entry under, so two
+ * spellings of one entry are one location (`endpoints.custom.ollama` for `Ollama`).
+ */
+export function entryLocation(path: string, name: string): string {
+ const servedName = KEYED_COLLECTIONS.get(path)?.servedName;
+ return `${path}.${servedName ? servedName(name) : name}`;
+}
+
/**
* The keyed collection `path` is an entry of, or a field inside an entry of
* (`endpoints.custom.3.baseURL` → `endpoints.custom`, index 3, rest `baseURL`).
diff --git a/src/utils/contract.test.ts b/src/utils/contract.test.ts
index cdf8f46f..85e54c20 100644
--- a/src/utils/contract.test.ts
+++ b/src/utils/contract.test.ts
@@ -1,11 +1,22 @@
import { describe, it, expect } from 'vitest';
+import type * as t from '@/types';
import {
+ getWriteRules,
+ resolveWriteRules,
+ getReadOnlyReason,
+ isYamlManagedPath,
+ isStartupOnlyPath,
+ BUNDLED_WRITE_RULES,
+ YAML_MANAGED_FIELDS,
+ STARTUP_ONLY_FIELDS,
CONFIG_FEATURE_FLAGS,
BUNDLED_CONFIG_RULES,
BUNDLED_CONFIG_LIMITS,
BUNDLED_CONFIG_CONTRACT,
+ READ_ONLY_CONFIG_SECTIONS,
} from '@/constants';
-import { resolveConfigContract } from './contract';
+import { createServerContract } from '@/test/fixtures';
+import { resolveConfigContract, foldsEndpointReferences } from './contract';
describe('resolveConfigContract', () => {
it('is the bundled contract, every feature off, for a server without the meta route', () => {
@@ -78,3 +89,86 @@ describe('BUNDLED_CONFIG_RULES', () => {
});
});
});
+
+/** `rules.deploymentOnlyPaths` as LibreChat with the write report publishes it (abridged). */
+const DEPLOYMENT_ONLY: t.ConfigRules['deploymentOnlyPaths'] = {
+ rateLimits: 'operator',
+ cloudfront: 'operator',
+ includedTools: 'operator',
+ skillDeletion: 'startup',
+ cache: 'unused',
+ 'registration.oauthStateTtlMs': 'startup',
+ 'endpoints.agents.eventDriven': 'startup',
+ 'endpoints.agents.toolApproval.hooks': 'operator',
+};
+
+describe('write rules', () => {
+ it('keeps the read-only sections and fields the panel knew for a server without rules', () => {
+ expect(getWriteRules(BUNDLED_CONFIG_CONTRACT)).toBe(BUNDLED_WRITE_RULES);
+ expect([...BUNDLED_WRITE_RULES.readOnlySections].sort()).toEqual(
+ [...READ_ONLY_CONFIG_SECTIONS].sort(),
+ );
+ expect(Object.fromEntries(READ_ONLY_CONFIG_SECTIONS)).toEqual({
+ filters: 'baseOnly',
+ mcpAppSandbox: 'baseOnly',
+ cloudfront: 'yamlOnly',
+ rateLimits: 'yamlOnly',
+ turnstile: 'yamlOnly',
+ permissions: 'yamlOnly',
+ openapi: 'yamlOnly',
+ cache: 'yamlOnly',
+ });
+ expect(YAML_MANAGED_FIELDS).toEqual(BUNDLED_WRITE_RULES.yamlManagedFields);
+ expect(isYamlManagedPath('registration.socialLogins')).toBe(true);
+ expect(isYamlManagedPath('endpoints.azureOpenAI.groups.0.apiKey')).toBe(true);
+ expect(isStartupOnlyPath('mcpSettings.catalogRecovery.maxStateEntries')).toBe(true);
+ expect(STARTUP_ONLY_FIELDS.has('mcpSettings.catalogRecovery.authorizationFenceTimeoutMs')).toBe(
+ false,
+ );
+ });
+
+ it("reads sections and fields from a server's base-only and deployment-only lists", () => {
+ const rules = resolveWriteRules(
+ {
+ baseOnlySections: ['filters', 'mcpAppSandbox', 'turnstile'],
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ deploymentOnlyPaths: DEPLOYMENT_ONLY,
+ },
+ false,
+ );
+ expect(getReadOnlyReason('includedTools', rules)).toBe('yamlOnly');
+ expect(getReadOnlyReason('skillDeletion.intervalMs', rules)).toBe('yamlOnly');
+ expect(getReadOnlyReason('filters.pii', rules)).toBe('baseOnly');
+ expect(getReadOnlyReason('turnstile.siteKey', rules)).toBe('yamlOnly');
+ expect(getReadOnlyReason('permissions', rules)).toBeUndefined();
+ expect(isYamlManagedPath('endpoints.agents.pullRequests.provider', rules)).toBe(true);
+ expect(isYamlManagedPath('endpoints.agents.pullRequests.host', rules)).toBe(false);
+ expect(isYamlManagedPath('endpoints.agents.toolApproval.hooks.0', rules)).toBe(true);
+ expect(isStartupOnlyPath('endpoints.agents.eventDriven', rules)).toBe(true);
+ expect(isStartupOnlyPath('endpoints.agents.toolApproval.hooks', rules)).toBe(false);
+ expect(isStartupOnlyPath('mcpSettings.catalogRecovery.maxStateEntries', rules)).toBe(false);
+ });
+
+ it('lets Base set the social sign-in buttons once Base overrides apply before sign-in', () => {
+ const before = getWriteRules(createServerContract());
+ const after = getWriteRules(createServerContract({ preAuthBaseOverrides: true }));
+ expect(isYamlManagedPath('registration.socialLogins', before)).toBe(true);
+ expect(isYamlManagedPath('registration.socialLogins', after)).toBe(false);
+ expect(isYamlManagedPath('registration.oauthStateTtlMs', after)).toBe(true);
+ expect(isYamlManagedPath('endpoints.anthropic.vertex.region', after)).toBe(true);
+ });
+
+ it('builds the rules once per contract', () => {
+ const contract = createServerContract({ preAuthBaseOverrides: true });
+ expect(getWriteRules(contract)).toBe(getWriteRules(contract));
+ });
+});
+
+describe('foldsEndpointReferences', () => {
+ it('is reported by per-field title resolution, which shipped with it', () => {
+ expect(foldsEndpointReferences(BUNDLED_CONFIG_CONTRACT)).toBe(false);
+ expect(foldsEndpointReferences(createServerContract({}, { titleResolution: 'field' }))).toBe(
+ true,
+ );
+ });
+});
diff --git a/src/utils/contract.ts b/src/utils/contract.ts
index 46ac0c53..27a8d238 100644
--- a/src/utils/contract.ts
+++ b/src/utils/contract.ts
@@ -35,6 +35,16 @@ export function resolveConfigContract(body: t.AdminConfigMetaBody | null): t.Con
};
}
+/**
+ * Whether LibreChat folds endpoint references to the names it serves
+ * (`Ollama` and `ollama` are one endpoint in `addedEndpoints`, a spec's
+ * endpoint and `allowedProviders`). It shipped with per-field title
+ * resolution and has no flag of its own, so `titleResolution: 'field'` reports it.
+ */
+export function foldsEndpointReferences(contract: t.ConfigContract): boolean {
+ return contract.rules.titleResolution === 'field';
+}
+
/** 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 {};
diff --git a/src/utils/errors.ts b/src/utils/errors.ts
index 510ca3c7..848b185c 100644
--- a/src/utils/errors.ts
+++ b/src/utils/errors.ts
@@ -1,5 +1,7 @@
import type * as t from '@/types';
import { TERMS_NOT_ACCEPTED_CODE } from '@/constants';
+import translation from '@/locales/en/translation.json';
+import { interpolateIssue } from './validation';
const TERMS_PREFIX = `${TERMS_NOT_ACCEPTED_CODE} `;
@@ -30,3 +32,17 @@ export function errorText(error: unknown, fallback: string, localize: t.Localize
if (termsUrl === '') return localize('com_config_terms_refused');
return messageOf(error) || fallback;
}
+
+/**
+ * English text for a stable admin API error code the panel words itself
+ * (`com_config_api_error_`), with the section a `FORBIDDEN_SECTION`
+ * names; null for a code it does not word, whose own text is kept. Error
+ * replies are read on the server, before the page localizes them.
+ */
+export function describeApiError(body: t.ApiErrorBody): string | null {
+ if (!body.code) return null;
+ const key = `com_config_api_error_${body.code.toLowerCase()}` as keyof typeof translation;
+ const template = translation[key];
+ if (typeof template !== 'string') return null;
+ return interpolateIssue(template, { section: body.section ?? '' });
+}
diff --git a/src/utils/layers.test.ts b/src/utils/layers.test.ts
index 1cc53844..793f8e67 100644
--- a/src/utils/layers.test.ts
+++ b/src/utils/layers.test.ts
@@ -10,6 +10,7 @@ import {
import { applyConfigEdit, buildSavePayload } from '@/components/configuration/edits';
import { scopeLayersFixture, baseConfigFixture } from '@/test/layers';
import { flattenObject, getValueAtPath } from './format';
+import { resolveWriteRules } from '@/constants';
describe('mergeScopeLayer', () => {
const base: t.ConfigRecord = {
@@ -67,6 +68,58 @@ describe('mergeScopeLayer', () => {
expect(Object.keys(merged.mcpServers as t.ConfigRecord)).toEqual(['github']);
expect(base.mcpServers).toHaveProperty('docs');
});
+
+ it("keeps Base's value at the base-only and deployment-only paths a server reports, at any depth", () => {
+ const rules = resolveWriteRules(
+ {
+ baseOnlySections: [],
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ deploymentOnlyPaths: {
+ 'endpoints.agents.eventDriven': 'startup',
+ 'mcpSettings.catalogRecovery.maxStateEntries': 'startup',
+ },
+ },
+ true,
+ );
+ const yaml: t.ConfigRecord = {
+ endpoints: {
+ agents: {
+ pullRequests: { provider: 'github', host: 'github.com' },
+ eventDriven: { enabled: true },
+ },
+ },
+ mcpSettings: { catalogRecovery: { maxStateEntries: 100, retryMs: 5 } },
+ };
+ const overridden = mergeScopeLayer(
+ yaml,
+ {
+ endpoints: { agents: { pullRequests: { provider: 'gitlab' }, eventDriven: { x: 1 } } },
+ mcpSettings: { catalogRecovery: { retryMs: 9 } },
+ },
+ ['mcpSettings.catalogRecovery.maxStateEntries'],
+ rules,
+ );
+ expect(overridden).toEqual({
+ endpoints: {
+ agents: {
+ pullRequests: { provider: 'github', host: 'github.com' },
+ eventDriven: { enabled: true },
+ },
+ },
+ mcpSettings: { catalogRecovery: { maxStateEntries: 100, retryMs: 9 } },
+ });
+ const hidden = mergeScopeLayer(yaml, {}, ['endpoints.agents'], rules);
+ expect(hidden.endpoints).toEqual({
+ agents: { pullRequests: { provider: 'github' }, eventDriven: { enabled: true } },
+ });
+ const added = mergeScopeLayer(
+ {},
+ { endpoints: { agents: { eventDriven: { x: 1 } } } },
+ [],
+ rules,
+ );
+ expect(getValueAtPath(added, 'endpoints.agents.eventDriven')).toBeUndefined();
+ });
});
describe('removeConfigPath / setConfigPath', () => {
diff --git a/src/utils/layers.ts b/src/utils/layers.ts
index 5e62460f..df19741c 100644
--- a/src/utils/layers.ts
+++ b/src/utils/layers.ts
@@ -1,5 +1,5 @@
import type * as t from '@/types';
-import { DEDICATED_CONFIG_SECTIONS, READ_ONLY_CONFIG_SECTIONS } from '@/constants';
+import { BUNDLED_WRITE_RULES, DEDICATED_CONFIG_SECTIONS } from '@/constants';
import { deepSerializeKVPairs, getValueAtPath, isConfigRecord } from './format';
import { collectLeafPaths, createPathIndex } from './paths';
import { mapSecretPreviewPaths } from './secrets';
@@ -131,29 +131,51 @@ export function setConfigPath(
return root;
}
+/**
+ * `merged` with Base's value put back at each of `paths`, whatever an override
+ * or tombstone did there, inside it or at an ancestor (as LibreChat keeps its
+ * base-only paths).
+ */
+function keepBaseValues(
+ base: t.ConfigRecord,
+ merged: t.ConfigRecord,
+ paths: ReadonlySet,
+): t.ConfigRecord {
+ let result = merged;
+ for (const path of paths) {
+ const value = getValueAtPath(base, path);
+ if (getValueAtPath(result, path) !== value) result = setConfigPath(result, path, value);
+ }
+ return result;
+}
+
/**
* The config a profile's members get, as far as one profile can tell: Base
* (librechat.yaml plus base overrides) with the profile's overrides merged the
* way LibreChat merges them (objects deep-merge, `endpoints.custom` merges by
* name, other arrays are replaced) and its tombstoned paths removed. Sections a
- * profile cannot override keep their Base values.
+ * profile cannot override (read-only by the server's write `rules`) and the
+ * fields only librechat.yaml sets inside the others (`yamlManagedFields`: the
+ * server's base-only and deployment-only paths) keep their Base values.
*/
export function mergeScopeLayer(
base: t.ConfigRecord,
overrides: t.ConfigRecord,
tombstones: Iterable = [],
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
): t.ConfigRecord {
+ const readOnly = rules.readOnlySections;
const applicable: t.ConfigRecord = {};
for (const [section, value] of Object.entries(overrides)) {
- if (READ_ONLY_CONFIG_SECTIONS.has(section) || DEDICATED_CONFIG_SECTIONS.has(section)) continue;
+ if (readOnly.has(section) || DEDICATED_CONFIG_SECTIONS.has(section)) continue;
applicable[section] = value;
}
let merged = mergeRecords(base, applicable, '');
for (const path of tombstones) {
- if (READ_ONLY_CONFIG_SECTIONS.has(path.split('.')[0])) continue;
+ if (readOnly.has(path.split('.')[0])) continue;
merged = removeConfigPath(merged, path);
}
- return merged;
+ return keepBaseValues(base, merged, rules.yamlManagedFields);
}
function isStructuralEdit(value: t.ConfigValue): boolean {
@@ -218,6 +240,7 @@ export function withScopeLayer(
scope: Pick,
data: t.ResolvedConfigData,
schemaPaths: ReadonlySet,
+ rules?: t.ConfigWriteRules,
): t.ValueLayers {
return {
...layers,
@@ -228,6 +251,6 @@ export function withScopeLayer(
overridePaths: createPathIndex(mapSecretPreviewPaths(data.changedPaths, schemaPaths)),
tombstones: new Set(data.tombstones),
},
- effective: mergeScopeLayer(layers.base ?? {}, data.overrides, data.tombstones),
+ effective: mergeScopeLayer(layers.base ?? {}, data.overrides, data.tombstones, rules),
};
}
diff --git a/src/utils/scopes.test.ts b/src/utils/scopes.test.ts
index 46123de1..543cb568 100644
--- a/src/utils/scopes.test.ts
+++ b/src/utils/scopes.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { PrincipalType } from 'librechat-data-provider';
import type * as t from '@/types';
+import { resolveWriteRules } from '@/constants';
import {
isSameScope,
applyScopeGates,
@@ -11,6 +12,7 @@ import {
isCurrentSelection,
countScopeOverrides,
collectOverridePaths,
+ compareScopePrecedence,
scopeGateSkipReason,
scopeOverrideEntries,
listScopeOverrideItems,
@@ -149,6 +151,41 @@ describe('findPriorityTies', () => {
it('reports nothing for an inactive profile', () => {
expect(findPriorityTies(off, [eng, design, off])).toEqual([]);
});
+
+ it('says which profile applies last on equal priority (deterministicPrecedence)', () => {
+ const role = scope({
+ _id: 'b',
+ principalType: PrincipalType.ROLE,
+ principalId: 'USER',
+ overridePaths: ['interface.parameters'],
+ });
+ const older = scope({ _id: 'a', principalId: 'older', overridePaths: ['interface'] });
+ const newer = scope({ _id: 'c', principalId: 'newer', overridePaths: ['interface'] });
+ const ties = findPriorityTies(older, [role, older, newer]);
+ expect(ties.map((tie) => [tie.scope.principalId, tie.appliesAfter])).toEqual([
+ ['USER', true],
+ ['newer', false],
+ ]);
+ expect(findPriorityTies(role, [role, older])[0].appliesAfter).toBe(false);
+ });
+});
+
+describe('compareScopePrecedence', () => {
+ it('orders by priority, then roles before groups before users, then the older profile first', () => {
+ const user = scope({ _id: 'a', principalType: PrincipalType.USER, principalId: 'u1' });
+ const role = scope({ _id: 'z', principalType: PrincipalType.ROLE, principalId: 'USER' });
+ const groupOld = scope({ _id: '6a00', principalId: 'old' });
+ const groupNew = scope({ _id: '6a01', principalId: 'new' });
+ const low = scope({
+ _id: 'zz',
+ principalType: PrincipalType.USER,
+ principalId: 'u2',
+ priority: 5,
+ });
+ const ordered = [user, groupNew, role, low, groupOld].sort(compareScopePrecedence);
+ expect(ordered.map((s) => s.principalId)).toEqual(['u2', 'USER', 'old', 'new', 'u1']);
+ expect(compareScopePrecedence(groupOld, { ...groupOld })).toBe(0);
+ });
});
describe('buildScopeIndex', () => {
@@ -220,4 +257,19 @@ describe('scopeGateSkipReason', () => {
expect(scopeGateSkipReason('langfuse.baseUrl')).toBe('dedicated');
expect(scopeGateSkipReason('balance.startBalance')).toBeUndefined();
});
+
+ it("gates the sections the server's rules make read-only", () => {
+ const rules = resolveWriteRules(
+ {
+ baseOnlySections: ['filters', 'turnstile'],
+ baseOnlyPaths: [],
+ deploymentOnlyPaths: { includedTools: 'operator' },
+ },
+ true,
+ );
+ expect(scopeGateSkipReason('includedTools', rules)).toBe('yamlOnly');
+ expect(scopeGateSkipReason('rateLimits.fileUploads.ipMax', rules)).toBeUndefined();
+ expect(scopeGateSkipReason('turnstile.siteKey', rules)).toBe('yamlOnly');
+ expect(scopeGateSkipReason('registration.socialLogins', rules)).toBe('baseScope');
+ });
});
diff --git a/src/utils/scopes.ts b/src/utils/scopes.ts
index f7cf6b2a..654af885 100644
--- a/src/utils/scopes.ts
+++ b/src/utils/scopes.ts
@@ -12,6 +12,28 @@ const GATE_SKIP_REASONS: Readonly> = {
preAuth: 'baseScope',
};
+/** Order of profile kinds on equal priority under `deterministicPrecedence`: roles, then groups, then users. */
+const TYPE_RANK: Readonly> = {
+ [PrincipalType.ROLE]: 0,
+ [PrincipalType.GROUP]: 1,
+ [PrincipalType.USER]: 2,
+};
+
+/**
+ * The order LibreChat applies profiles in with `deterministicPrecedence`,
+ * the last winning: ascending priority, then roles before groups before
+ * users, then the older profile first (document id, roughly creation order).
+ * Base always applies before every profile.
+ */
+export function compareScopePrecedence(a: t.ConfigScope, b: t.ConfigScope): number {
+ if (a.priority !== b.priority) return a.priority - b.priority;
+ const rank = (TYPE_RANK[a.principalType] ?? 0) - (TYPE_RANK[b.principalType] ?? 0);
+ if (rank !== 0) return rank;
+ const [idA, idB] = [a._id ?? '', b._id ?? ''];
+ if (idA === idB) return 0;
+ return idA < idB ? -1 : 1;
+}
+
/** Profile kinds a user can only belong to once, so two of them never apply together. */
const SINGLE_MEMBERSHIP_TYPES: ReadonlySet = new Set([
PrincipalType.ROLE,
@@ -122,8 +144,10 @@ function canCoApply(a: t.ConfigScope, b: t.ConfigScope): boolean {
/**
* Active profiles with the same priority as `scope` that can apply to the same
- * user. LibreChat orders profiles by priority only, so for a user both apply
- * to, which value of a setting they both change wins is undefined.
+ * user, with whether `scope` applies after each (`compareScopePrecedence`).
+ * Before `deterministicPrecedence` LibreChat orders profiles by priority
+ * only, so for a user both apply to, which value of a setting they both
+ * change wins is undefined; with it, the one that applies last wins.
*/
export function findPriorityTies(
scope: t.ConfigScope,
@@ -142,7 +166,11 @@ export function findPriorityTies(
.map((other) => {
const otherPaths = scopePaths(other);
const shared = own.filter((path) => otherPaths.some((p) => pathsOverlap(path, p)));
- return { scope: other, sharedPaths: shared };
+ return {
+ scope: other,
+ sharedPaths: shared,
+ appliesAfter: compareScopePrecedence(scope, other) > 0,
+ };
});
}
@@ -155,10 +183,11 @@ function pathPrefixes(path: string): string[] {
/**
* Indexes profiles by every path they override or hide and each ancestor of
* it, so a settings row finds the profiles that change it (or something inside
- * it) with one lookup. Profiles are listed in priority order, lowest first.
+ * it) with one lookup. Profiles are listed in the order they apply, lowest
+ * priority first (`compareScopePrecedence`).
*/
export function buildScopeIndex(scopes: t.ConfigScope[]): t.ScopeIndex {
- const ordered = [...scopes].sort((a, b) => a.priority - b.priority);
+ const ordered = [...scopes].sort(compareScopePrecedence);
const byPath = new Map();
for (const scope of ordered) {
const seen = new Set();
@@ -194,16 +223,20 @@ export function getScopeIndex(scopes: t.ConfigScope[]): t.ScopeIndex {
export function applyScopeGates(
sections: t.ConfigSectionConfig[],
isEditingScope: boolean,
+ rules?: t.ConfigWriteRules,
): t.ConfigSectionConfig[] {
if (!isEditingScope) return sections;
return sections.map((section) => {
- const scopeGate = getScopeGateReason(section.schemaKey ?? section.id);
+ const scopeGate = getScopeGateReason(section.schemaKey ?? section.id, rules);
return scopeGate ? { ...section, scopeGate, tocItems: [] } : section;
});
}
/** Why a change to `path` is not sent to a profile: its section applies to everyone (PR-7). */
-export function scopeGateSkipReason(path: string): t.SkipReason | undefined {
- const gate = getScopeGateReason(path);
+export function scopeGateSkipReason(
+ path: string,
+ rules?: t.ConfigWriteRules,
+): t.SkipReason | undefined {
+ const gate = getScopeGateReason(path, rules);
return gate ? GATE_SKIP_REASONS[gate] : undefined;
}
diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts
new file mode 100644
index 00000000..4c31cee9
--- /dev/null
+++ b/src/utils/validation.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect } from 'vitest';
+import { classifyReportedIssue, describeIssueCode, paramlessIssueCode } from './validation';
+import { describeApiError, errorText } from './errors';
+
+function wording(code: string, params?: Parameters[1]): string {
+ const issue = classifyReportedIssue(code, params);
+ return describeIssueCode(issue.code, issue.params, 'is not a valid value');
+}
+
+describe('classifyReportedIssue', () => {
+ it('words a code without its parameters when the server reports none', () => {
+ expect(classifyReportedIssue('too_small', undefined)).toEqual({ code: 'too_small_value' });
+ expect(classifyReportedIssue('custom', undefined)).toEqual({ code: 'custom' });
+ expect(paramlessIssueCode('dotted_key')).toBe('dotted_key');
+ });
+
+ it('words bounds by what they bound and whether they are inclusive', () => {
+ expect(
+ wording('too_small', { minimum: 1, inclusive: true, exact: false, type: 'number' }),
+ ).toBe('must be at least 1');
+ expect(
+ wording('too_big', { maximum: 100, inclusive: false, exact: false, type: 'number' }),
+ ).toBe('must be less than 100');
+ expect(wording('too_small', { minimum: 3, inclusive: true, type: 'string' })).toBe(
+ 'must be at least 3 characters',
+ );
+ expect(wording('too_big', { maximum: 5, inclusive: true, type: 'array' })).toBe(
+ 'must have at most 5 items',
+ );
+ expect(classifyReportedIssue('too_small', { inclusive: true, type: 'date' })).toEqual({
+ code: 'too_small_value',
+ });
+ });
+
+ it('words expected types, literals and options', () => {
+ expect(wording('invalid_type', { expected: 'integer', received: 'number' })).toBe(
+ 'must be a whole number',
+ );
+ expect(wording('invalid_type', { expected: 'string', received: 'undefined' })).toBe(
+ 'is required',
+ );
+ expect(wording('invalid_literal', { expected: 'stdio' })).toBe('must be "stdio"');
+ expect(wording('invalid_enum_value', { options: ['low', 'high'] })).toBe(
+ 'must be one of "low", "high"',
+ );
+ expect(wording('invalid_union_discriminator', { options: ['sse', 'http'] })).toBe(
+ 'must be one of "sse", "http"',
+ );
+ expect(wording('invalid_enum_value', { options: [] })).toBe('is not one of the allowed values');
+ });
+
+ it('words string checks, multiples and a refinement by its reason', () => {
+ expect(wording('invalid_string', { validation: 'url' })).toBe('must be a valid URL');
+ expect(wording('invalid_string', { validation: 'regex' })).toBe('is not a valid value');
+ expect(wording('not_multiple_of', { multipleOf: 5 })).toBe('must be a multiple of 5');
+ expect(wording('custom', { reason: 'missing_port' })).toBe(
+ 'must include a port, for example localhost:11434',
+ );
+ expect(wording('custom', { reason: 'public_ip' })).toBe(
+ 'is a public IP address: enter a private address, or a hostname that resolves to one',
+ );
+ expect(classifyReportedIssue('custom', { reason: 'some_new_rule' })).toEqual({
+ code: 'custom',
+ });
+ });
+});
+
+describe('describeApiError', () => {
+ it("words the admin API's stable codes, and leaves others to the server's text", () => {
+ expect(describeApiError({ code: 'PRINCIPAL_NOT_FOUND' })).toMatch(/doesn't exist/);
+ expect(describeApiError({ code: 'FORBIDDEN_SECTION', section: 'memory' })).toBe(
+ "You don't have permission to change the memory settings.",
+ );
+ expect(describeApiError({ code: 'CONFIG_VERSION_CONFLICT' })).toMatch(/Reload the page/);
+ expect(describeApiError({ code: 'AUTH_REQUIRED' })).toMatch(/session expired/);
+ expect(describeApiError({ code: 'SOME_FUTURE_CODE', error: 'Text' })).toBeNull();
+ expect(describeApiError({ error: 'Text' })).toBeNull();
+ });
+
+ it('keeps an error text as it is', () => {
+ const localize = (key: string) => key;
+ expect(errorText(new Error('There is no role named "X"'), 'fallback', localize)).toBe(
+ 'There is no role named "X"',
+ );
+ });
+});
diff --git a/src/utils/validation.ts b/src/utils/validation.ts
index 96d58f5a..59293720 100644
--- a/src/utils/validation.ts
+++ b/src/utils/validation.ts
@@ -135,6 +135,12 @@ const ISSUE_MESSAGES: Readonly> = {
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",
+ not_multiple_of: 'must be a multiple of {{multipleOf}}',
+ custom_empty: 'is empty: enter a host and a port',
+ custom_format: 'must be only host:port, without http:// or a path',
+ custom_missing_port: 'must include a port, for example localhost:11434',
+ custom_public_ip:
+ 'is a public IP address: enter a private address, or a hostname that resolves to one',
};
/**
@@ -231,6 +237,68 @@ function classifySchemaIssue(issue: t.SchemaIssue): {
}
}
+/** A bound the admin API reported as a number, or undefined (dates and bigints are not sent). */
+function numberBound(value: number | undefined): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
+
+/**
+ * The issue code and message parameters of an issue the admin API reported
+ * with `params` (`issueParams`): its bounds, options and expected type, read
+ * the way the panel reads its own schema issues; a refinement's `reason`
+ * becomes `custom_` where the panel words it. Without params, or
+ * without the parameter a code's message needs, the code is worded without
+ * it (`paramlessIssueCode`).
+ */
+export function classifyReportedIssue(
+ code: string,
+ params: t.ReportedIssueParams | undefined,
+): { code: string; params?: Record } {
+ if (!params) return { code: paramlessIssueCode(code) };
+ const bound = numberBound(code === 'too_big' ? params.maximum : params.minimum);
+ switch (code) {
+ case 'too_small':
+ case 'too_big':
+ if (bound === undefined) return { code: paramlessIssueCode(code) };
+ break;
+ case 'invalid_type':
+ case 'invalid_literal':
+ if (params.expected === undefined) return { code: paramlessIssueCode(code) };
+ break;
+ case 'invalid_enum_value':
+ case 'invalid_union_discriminator':
+ if (!params.options?.length) return { code: paramlessIssueCode('invalid_enum_value') };
+ return {
+ code: 'invalid_enum_value',
+ params: { options: params.options.map((option) => JSON.stringify(option)).join(', ') },
+ };
+ case 'not_multiple_of': {
+ const step = numberBound(params.multipleOf);
+ return step === undefined
+ ? { code: 'invalid_value' }
+ : { code, params: { multipleOf: step } };
+ }
+ case 'custom': {
+ const reasonCode = params.reason ? `custom_${params.reason}` : undefined;
+ return { code: reasonCode && reasonCode in ISSUE_MESSAGES ? reasonCode : code };
+ }
+ default:
+ break;
+ }
+ return classifySchemaIssue({
+ code,
+ path: [],
+ message: '',
+ expected: params.expected,
+ received: params.received,
+ minimum: bound,
+ maximum: bound,
+ inclusive: params.inclusive,
+ type: params.type,
+ validation: params.validation,
+ });
+}
+
/** Config issues (stable codes, English messages) for schema issues relative to `basePath`. */
export function toConfigIssues(issues: t.SchemaIssue[], basePath: string): t.ConfigIssue[] {
return expandUnionIssues(issues)
diff --git a/src/utils/values.test.ts b/src/utils/values.test.ts
index 989d4746..16f7bdf3 100644
--- a/src/utils/values.test.ts
+++ b/src/utils/values.test.ts
@@ -318,6 +318,66 @@ describe('resolveShadowedBy (endpoints.all precedence)', () => {
'gpt-5.6-luna',
);
});
+
+ describe("on a server that resolves titles per field (titleResolution: 'field')", () => {
+ const withTitles = (all: t.ConfigRecord): t.ConfigRecord => ({
+ endpoints: { all, openAI: { titleModel: 'gpt-5-mini', titleMethod: 'structured' } },
+ });
+
+ it('shadows a title setting only where `all` sets it', () => {
+ const tree = withTitles({ activityLabel: true, titleMethod: 'completion' });
+ expect(resolveShadowedBy('endpoints.openAI.titleModel', tree, {}, 'field')).toBeUndefined();
+ expect(resolveShadowedBy('endpoints.openAI.titleMethod', tree, {}, 'field')).toEqual({
+ path: 'endpoints.all.titleMethod',
+ value: 'completion',
+ precedence: 'field',
+ });
+ expect(resolveShadowedBy('endpoints.openAI.titleMethod', tree)?.precedence).toBe('block');
+ });
+
+ it('treats the title endpoint and model as one choice, pointing to the half `all` sets', () => {
+ const tree = withTitles({ titleEndpoint: 'anthropic' });
+ expect(resolveShadowedBy('endpoints.openAI.titleModel', tree, {}, 'field')).toEqual({
+ path: 'endpoints.all.titleEndpoint',
+ value: undefined,
+ precedence: 'pair',
+ });
+ expect(
+ resolveShadowedBy(
+ 'endpoints.custom.0.titleEndpoint',
+ withTitles({ titleModel: 'm' }),
+ {},
+ 'field',
+ ),
+ ).toEqual({ path: 'endpoints.all.titleModel', value: undefined, precedence: 'pair' });
+ expect(
+ resolveShadowedBy(
+ 'endpoints.openAI.titleModel',
+ withTitles({}),
+ {
+ 'endpoints.all.titleEndpoint': 'anthropic',
+ },
+ 'field',
+ )?.path,
+ ).toBe('endpoints.all.titleEndpoint');
+ expect(
+ resolveShadowedBy(
+ 'endpoints.openAI.titleModel',
+ tree,
+ { 'endpoints.all.titleEndpoint': undefined },
+ 'field',
+ ),
+ ).toBeUndefined();
+ });
+
+ it('is reported on the field state with the contract of the values', () => {
+ const data = valuesDataFixture(baseLayersFixture());
+ const state = (titleResolution?: t.ConfigRules['titleResolution']) =>
+ deriveFieldState('endpoints.openAI.titleModel', { ...data, titleResolution }).shadowedBy;
+ expect(state()?.precedence).toBe('block');
+ expect(state('field')).toBeUndefined();
+ });
+ });
});
describe('deriveFieldValue', () => {
diff --git a/src/utils/values.ts b/src/utils/values.ts
index 59d9c5ed..ed2b4220 100644
--- a/src/utils/values.ts
+++ b/src/utils/values.ts
@@ -40,8 +40,12 @@ const NO_VALUES_STATE: t.FieldState = {
isHidden: false,
};
-/** Title settings LibreChat reads from `endpoints.all` as a whole block whenever one exists. */
-const BLOCK_SHADOWED_KEYS: ReadonlySet = new Set([
+/**
+ * Title settings. LibreChat reads them from `endpoints.all` as a whole block
+ * whenever one exists (`titleResolution: 'block'`), or per field like the
+ * settings below (`'field'`), with the title endpoint and model as one choice.
+ */
+const TITLE_KEYS: ReadonlySet = new Set([
'titleConvo',
'titleModel',
'titleMethod',
@@ -50,6 +54,9 @@ const BLOCK_SHADOWED_KEYS: ReadonlySet = new Set([
'titlePromptTemplate',
]);
+/** With `titleResolution: 'field'`, both come from the first layer that sets either. */
+const TITLE_ROUTING_KEYS: readonly string[] = ['titleEndpoint', 'titleModel'];
+
/** 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',
@@ -86,15 +93,21 @@ function sameValue(a: t.ConfigValue, b: t.ConfigValue): boolean {
/**
* The `endpoints.all` setting that takes precedence over a provider or custom
- * endpoint field (EV-8). Titles read the whole `all` block whenever one exists
- * (`endpoints.all ?? endpoints[x]`), so every per-endpoint title setting is
- * ignored, even one that `all` leaves unset. Activity and reasoning-label
- * settings resolve per field (`all[key] ?? endpoint[key]`).
+ * endpoint field (EV-8). Activity and reasoning-label settings resolve per
+ * field (`all[key] ?? endpoint[key]`). Titles, on a server that resolves them
+ * as a block (`titleResolution: 'block'`), read the whole `all` block
+ * whenever one exists (`endpoints.all ?? endpoints[x]`), so every
+ * per-endpoint title setting is ignored, even one that `all` leaves unset;
+ * on one that resolves them per field (`'field'`), only a title setting `all`
+ * sets wins, and the title endpoint and model are one choice: both come from
+ * `all` when it sets either, so the half `all` leaves unset points to the
+ * half it sets.
*/
export function resolveShadowedBy(
path: string,
root: t.ConfigValue,
editedValues: t.FlatConfigMap = {},
+ titleResolution: t.ConfigRules['titleResolution'] = 'block',
): t.ShadowedBy | undefined {
const segments = path.split('.');
if (segments[0] !== 'endpoints') return undefined;
@@ -104,14 +117,21 @@ export function resolveShadowedBy(
const isCustomField =
segments.length === 4 && segments[1] === 'custom' && INDEX_SEGMENT.test(segments[2]);
if (!isProviderField && !isCustomField) return undefined;
- const isBlock = BLOCK_SHADOWED_KEYS.has(key);
- if (!isBlock && !FIELD_SHADOWED_KEYS.has(key)) return undefined;
+ const isTitle = TITLE_KEYS.has(key);
+ if (!isTitle && !FIELD_SHADOWED_KEYS.has(key)) return undefined;
const all = getValueAtPath(root, 'endpoints.all');
if (!isConfigRecord(all)) return undefined;
+ const valueOf = (name: string): t.ConfigValue => {
+ const allPath = `endpoints.all.${name}`;
+ return allPath in editedValues ? editedValues[allPath] : all[name];
+ };
const allPath = `endpoints.all.${key}`;
- const value = allPath in editedValues ? editedValues[allPath] : all[key];
- if (isBlock) return { path: allPath, value, precedence: 'block' };
- return value === undefined ? undefined : { path: allPath, value, precedence: 'field' };
+ const value = valueOf(key);
+ if (isTitle && titleResolution === 'block') return { path: allPath, value, precedence: 'block' };
+ if (value !== undefined) return { path: allPath, value, precedence: 'field' };
+ if (!isTitle || !TITLE_ROUTING_KEYS.includes(key)) return undefined;
+ const other = TITLE_ROUTING_KEYS.find((name) => name !== key && valueOf(name) !== undefined);
+ return other ? { path: `endpoints.all.${other}`, value, precedence: 'pair' } : undefined;
}
interface BaseReadout {
@@ -323,7 +343,7 @@ function deriveBaseModeState(
const revertValue = revertTarget ? revertTarget.value : base.effective;
const pending = readPending(path, data.editedValues, revertValue);
/** D5: LibreChat reads this section only from librechat.yaml, so a stored override has no effect. */
- if (base.isOverride && getReadOnlyReason(path) === 'yamlOnly') {
+ if (base.isOverride && getReadOnlyReason(path, data.writeRules) === 'yamlOnly') {
return {
value: pending.isEdited ? pending.value : base.lower.value,
effective: base.lower.value,
@@ -343,6 +363,7 @@ function deriveBaseModeState(
path,
data.configValues ?? layers.effective,
data.editedValues,
+ data.titleResolution,
);
return {
value: pending.isEdited ? pending.value : (shown ?? base.effective),
@@ -392,6 +413,7 @@ function deriveScopeModeState(
path,
data.configValues ?? layers.effective,
data.editedValues,
+ data.titleResolution,
);
return {
value: pending.isEdited ? pending.value : (shown ?? effective),
diff --git a/src/utils/writes.test.ts b/src/utils/writes.test.ts
new file mode 100644
index 00000000..fec7a451
--- /dev/null
+++ b/src/utils/writes.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect } from 'vitest';
+import type * as t from '@/types';
+import { BUNDLED_CONFIG_FEATURES, getWriteRules } from '@/constants';
+import { reportedPath, readWriteReport, getWriteSkipReason } from './writes';
+import { createServerContract } from '@/test/fixtures';
+import { entryLocation } from './collections';
+import { createPathIndex } from './paths';
+
+const REPORT: t.PatchFieldsResponse = {
+ config: { overrides: { interface: { modelSelect: true } } },
+ applied: [{ path: 'interface', segments: ['interface'] }],
+ skipped: [
+ {
+ path: 'interface.prompts',
+ segments: ['interface', 'prompts'],
+ reason: 'interface_permission',
+ },
+ { path: 'rateLimits', segments: ['rateLimits'], reason: 'deployment_only_path' },
+ {
+ path: 'endpoints.agents.pullRequests.provider',
+ segments: ['endpoints', 'agents', 'pullRequests', 'provider'],
+ reason: 'base_only_path',
+ },
+ { path: 'mcpServers.linear', segments: ['mcpServers', 'linear'], reason: 'process_mcp_server' },
+ { path: 'futureSection', segments: ['futureSection'], reason: 'some_future_reason' },
+ ],
+ ignored: [
+ {
+ path: 'endpoints.custom.Moonshot.apiKey',
+ segments: ['endpoints', 'custom', 'Moonshot', 'apiKey'],
+ reason: 'secret_rebinding',
+ },
+ ],
+};
+
+describe('readWriteReport', () => {
+ it('reads nothing from a server without a write report or credential rebinding', () => {
+ expect(readWriteReport(REPORT, BUNDLED_CONFIG_FEATURES)).toEqual([]);
+ });
+
+ it("words each location the server did not store, and each it leaves out, by the panel's reasons", () => {
+ const features = { ...BUNDLED_CONFIG_FEATURES, writeReport: true };
+ expect(readWriteReport(REPORT, features)).toEqual([
+ { fieldPath: 'interface.prompts', reason: 'permission' },
+ { fieldPath: 'rateLimits', reason: 'yamlOnly' },
+ { fieldPath: 'endpoints.agents.pullRequests.provider', reason: 'baseOnly' },
+ { fieldPath: 'mcpServers.linear', reason: 'processServer' },
+ { fieldPath: 'futureSection', reason: 'notStored' },
+ { fieldPath: 'endpoints.custom.Moonshot.apiKey', reason: 'secretRebinding' },
+ ]);
+ });
+
+ it('reads only the dropped credentials on a server that rebinds them without a write report', () => {
+ const features = { ...BUNDLED_CONFIG_FEATURES, secretRebinding: true };
+ expect(readWriteReport(REPORT, features)).toEqual([
+ { fieldPath: 'endpoints.custom.Moonshot.apiKey', reason: 'secretRebinding' },
+ ]);
+ });
+
+ describe('a dropped credential, for the changes a save made', () => {
+ const features = { ...BUNDLED_CONFIG_FEATURES, secretRebinding: true };
+ const dropped = (path: string, segments = path.split('.')) => ({
+ path,
+ segments,
+ reason: 'secret_rebinding',
+ });
+ const body: t.PatchFieldsResponse = {
+ ignored: [
+ dropped('endpoints.custom.Moonshot.apiKey'),
+ dropped('endpoints.custom.ollama.apiKey'),
+ dropped('endpoints.custom.Proxy.Two.apiKey', [
+ 'endpoints',
+ 'custom',
+ 'Proxy.Two',
+ 'apiKey',
+ ]),
+ dropped('mcpServers.github.apiKey.key'),
+ dropped('mcpServers.linear.oauth.client_secret'),
+ dropped('endpoints.azureOpenAI.groupMap.eastus.apiKey'),
+ dropped('ocr.apiKey'),
+ dropped('speech.tts.openai.apiKey'),
+ ],
+ };
+ const shownFor = (changed: string[]) =>
+ readWriteReport(body, features, createPathIndex(changed)).map(({ fieldPath }) => fieldPath);
+
+ it('is shown when the save changed its entry or the object holding it', () => {
+ expect(shownFor(['endpoints.custom.Moonshot'])).toEqual(['endpoints.custom.Moonshot.apiKey']);
+ expect(shownFor(['mcpServers.github.url'])).toEqual(['mcpServers.github.apiKey.key']);
+ expect(shownFor(['mcpServers.linear'])).toEqual(['mcpServers.linear.oauth.client_secret']);
+ expect(shownFor(['ocr.baseURL'])).toEqual(['ocr.apiKey']);
+ expect(shownFor(['speech.tts.openai.url'])).toEqual(['speech.tts.openai.apiKey']);
+ });
+
+ it('matches an entry by its served name, a dotted name whole, and Azure groups by their list', () => {
+ expect(shownFor([entryLocation('endpoints.custom', 'Ollama')])).toEqual([
+ 'endpoints.custom.ollama.apiKey',
+ ]);
+ expect(shownFor(['endpoints.custom.Proxy.Two'])).toEqual(['endpoints.custom']);
+ expect(shownFor(['endpoints.azureOpenAI.groups'])).toEqual([
+ 'endpoints.azureOpenAI.groupMap.eastus.apiKey',
+ ]);
+ });
+
+ it('is shown for a whole section the save wrote', () => {
+ expect(shownFor(['mcpServers'])).toEqual([
+ 'mcpServers.github.apiKey.key',
+ 'mcpServers.linear.oauth.client_secret',
+ ]);
+ });
+
+ it('is not repeated by a save elsewhere, even in the same section or record', () => {
+ expect(shownFor(['balance.startBalance'])).toEqual([]);
+ expect(shownFor(['endpoints.openAI.titleModel', 'endpoints.custom.GPT'])).toEqual([]);
+ expect(shownFor(['mcpServers.cloudflare.url', 'speech.tts.elevenlabs.url'])).toEqual([]);
+ expect(shownFor(['endpoints.azureOpenAI.titleModel', 'speech.stt.openai.url'])).toEqual([]);
+ });
+ });
+
+ it('ignores a reply without the lists, and a location it cannot name', () => {
+ const features = { ...BUNDLED_CONFIG_FEATURES, writeReport: true };
+ expect(readWriteReport({ config: null }, features)).toEqual([]);
+ expect(readWriteReport({ skipped: [{ reason: 'base_only_path' }] }, features)).toEqual([]);
+ });
+});
+
+describe('reportedPath', () => {
+ it('names a dotted record key by its record, and a location by its segments', () => {
+ expect(
+ reportedPath({
+ path: 'endpoints.bedrock.inferenceProfiles.us.anthropic.claude-sonnet-4-6',
+ segments: ['endpoints', 'bedrock', 'inferenceProfiles', 'us.anthropic.claude-sonnet-4-6'],
+ }),
+ ).toBe('endpoints.bedrock.inferenceProfiles');
+ expect(reportedPath({ segments: ['mcpServers', 'docs', 'url'] })).toBe('mcpServers.docs.url');
+ expect(reportedPath({ path: 'balance.startBalance' })).toBe('balance.startBalance');
+ expect(reportedPath({ segments: ['a.b'] })).toBe('a.b');
+ });
+});
+
+describe('getWriteSkipReason', () => {
+ const rules = getWriteRules(
+ createServerContract(
+ { preAuthBaseOverrides: true },
+ {
+ baseOnlySections: ['filters', 'turnstile'],
+ baseOnlyPaths: ['endpoints.agents.pullRequests.provider'],
+ deploymentOnlyPaths: {
+ includedTools: 'operator',
+ 'endpoints.agents.eventDriven': 'startup',
+ },
+ },
+ ),
+ );
+
+ it("follows the server's rules: what it reads only from librechat.yaml, and what Base may now set", () => {
+ expect(getWriteSkipReason('registration.socialLogins', false)).toBe('yamlOnly');
+ expect(getWriteSkipReason('registration.socialLogins', false, rules)).toBeNull();
+ expect(getWriteSkipReason('includedTools', false, rules)).toBe('yamlOnly');
+ expect(getWriteSkipReason('includedTools', true, rules)).toBeNull();
+ expect(getWriteSkipReason('endpoints.agents.pullRequests.provider', false, rules)).toBe(
+ 'yamlOnly',
+ );
+ expect(getWriteSkipReason('endpoints.agents.eventDriven', false, rules)).toBe('yamlOnly');
+ expect(getWriteSkipReason('filters.pii', true, rules)).toBe('baseOnly');
+ expect(getWriteSkipReason('rateLimits.fileUploads.ipMax', false, rules)).toBeNull();
+ expect(getWriteSkipReason('rateLimits.fileUploads.ipMax', false)).toBe('yamlOnly');
+ });
+});
diff --git a/src/utils/writes.ts b/src/utils/writes.ts
index 61c33f90..95fa4192 100644
--- a/src/utils/writes.ts
+++ b/src/utils/writes.ts
@@ -1,7 +1,9 @@
import type * as t from '@/types';
import {
SKIP_REASON_KEYS,
- YAML_MANAGED_FIELDS,
+ BUNDLED_WRITE_RULES,
+ REPORTED_SKIP_REASONS,
+ CREDENTIAL_ENTRY_RECORDS,
DEDICATED_CONFIG_SECTIONS,
getConfigSection,
getReadOnlyReason,
@@ -9,8 +11,21 @@ import {
} from '@/constants';
import { isInterfacePermissionPath } from './interfacePermissions';
import translation from '@/locales/en/translation.json';
+import { pathIndexOverlaps } from './paths';
+import { entryLocation } from './collections';
import { getValueAtPath } from './format';
+/** Skipped changes without repeats (the client, the server and each PATCH can report the same path). */
+export function uniqueSkipped(skipped: t.SkippedChange[]): t.SkippedChange[] {
+ const seen = new Set();
+ return skipped.filter(({ fieldPath, reason }) => {
+ const key = `${fieldPath}|${reason}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
/** English text for a skip reason, for server-side errors (the UI localizes `SKIP_REASON_KEYS`). */
export function describeSkipReason(reason: t.SkipReason): string {
const key = SKIP_REASON_KEYS[reason] as keyof typeof translation;
@@ -23,21 +38,102 @@ export function describeSkipReason(reason: t.SkipReason): string {
* pre-flight and every write path (save, import, profile saves). Removing a
* stale override of a YAML-managed field, or of a section LibreChat only reads
* from librechat.yaml, is allowed (it only removes data); writing one is not.
+ * `rules` are the server's (`getWriteRules`); the bundled ones by default.
*/
-export function getWriteSkipReason(path: string, isReset: boolean): t.SkipReason | null {
+export function getWriteSkipReason(
+ path: string,
+ isReset: boolean,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): t.SkipReason | null {
if (isInterfacePermissionPath(path)) return 'permission';
- const readOnly = getReadOnlyReason(path);
+ const readOnly = getReadOnlyReason(path, rules);
if (readOnly && !(isReset && readOnly === 'yamlOnly')) return readOnly;
if (DEDICATED_CONFIG_SECTIONS.has(getConfigSection(path))) return 'dedicated';
- if (!isReset && isYamlManagedPath(path)) return 'yamlOnly';
+ if (!isReset && isYamlManagedPath(path, rules)) return 'yamlOnly';
return null;
}
/** YAML-managed fields carried inside a value saved at an ancestor path (e.g. `endpoints.azureOpenAI`). */
-export function findYamlManagedValues(path: string, value: t.ConfigValue): string[] {
+export function findYamlManagedValues(
+ path: string,
+ value: t.ConfigValue,
+ rules: t.ConfigWriteRules = BUNDLED_WRITE_RULES,
+): string[] {
const prefix = `${path}.`;
- return [...YAML_MANAGED_FIELDS].filter(
+ return [...rules.yamlManagedFields].filter(
(managed) =>
managed.startsWith(prefix) && getValueAtPath(value, managed.slice(prefix.length)) != null,
);
}
+
+/**
+ * The path of a location the admin API reports, as the page shows it: a
+ * record key that holds a dot (`us.anthropic.claude-sonnet-4-6`) can't be
+ * told apart in a dotted path, so the location is its record's.
+ */
+export function reportedPath(location: t.ReportedLocation): string {
+ const { segments } = location;
+ if (!Array.isArray(segments) || segments.length === 0) return location.path ?? '';
+ const dotted = segments.findIndex((segment) => segment.includes('.'));
+ return (dotted === -1 ? segments : segments.slice(0, Math.max(dotted, 1))).join('.');
+}
+
+function toSkippedChanges(
+ items: t.ReportedSkip[] | undefined,
+ fallback: t.SkipReason,
+): t.SkippedChange[] {
+ if (!Array.isArray(items)) return [];
+ return items
+ .map((item) => ({
+ fieldPath: reportedPath(item),
+ reason: REPORTED_SKIP_REASONS[item.reason] ?? fallback,
+ }))
+ .filter((item) => item.fieldPath !== '');
+}
+
+/** A reported location key by key: its `segments`, else its dotted `path`. */
+function segmentsOf(location: t.ReportedLocation): string[] {
+ const { segments } = location;
+ if (Array.isArray(segments) && segments.length > 0) return segments;
+ return (location.path ?? '').split('.');
+}
+
+/**
+ * Where a credential the merge leaves out is set together with the destination
+ * it is sent to (`secretRebinding`): its entry in a record of named entries
+ * (`mcpServers.github` for `mcpServers.github.apiKey.key`, `CREDENTIAL_ENTRY_RECORDS`),
+ * else the object that holds it (`ocr` for `ocr.apiKey`).
+ */
+function credentialOwner(segments: readonly string[]): string {
+ for (let depth = 1; depth < segments.length; depth++) {
+ const edited = CREDENTIAL_ENTRY_RECORDS.get(segments.slice(0, depth).join('.'));
+ if (edited) return entryLocation(edited, segments[depth]);
+ }
+ return segments.slice(0, Math.max(segments.length - 1, 1)).join('.');
+}
+
+/**
+ * What a write's reply says did not take effect, with the panel's reasons:
+ * each location it did not store (`skipped`, from a server with
+ * `writeReport`), then each stored value the runtime merge leaves out
+ * (`ignored`: with `writeReport`, or a credential `secretRebinding` drops).
+ * A server without them reports neither, and nothing is read. `ignored`
+ * describes the whole stored document, so with the locations the save
+ * changes (`changed`, `WritePlan.changed`) only an entry whose owner the save
+ * changed is kept (`credentialOwner`: the custom endpoint, MCP server or
+ * object it is set in). A save elsewhere, even in the same section, does not
+ * repeat it, and one that no longer applies is not reported.
+ */
+export function readWriteReport(
+ body: t.PatchFieldsResponse,
+ features: t.ConfigFeatures,
+ changed?: t.PathIndex,
+): t.SkippedChange[] {
+ const skipped = features.writeReport ? toSkippedChanges(body.skipped, 'notStored') : [];
+ const readsIgnored = features.writeReport || features.secretRebinding;
+ if (!readsIgnored || !Array.isArray(body.ignored)) return skipped;
+ const ignored = changed
+ ? body.ignored.filter((item) => pathIndexOverlaps(changed, credentialOwner(segmentsOf(item))))
+ : body.ignored;
+ return [...skipped, ...toSkippedChanges(ignored, 'notApplied')];
+}