From 4cf7bed62ae079c8f4f96dab879cd1484807ce19 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 01/21] =?UTF-8?q?=F0=9F=A7=AD=20feat:=20Purpose-Built=20AI?= =?UTF-8?q?=20Providers=20Editor=20(Wave=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purpose-built AI providers editor (WS9, AP-1…AP-10). --- e2e/providers.spec.ts | 127 +++++ .../configuration/fields/ListField.tsx | 10 +- .../configuration/fields/ModelListField.tsx | 40 +- .../configuration/fields/RefListField.tsx | 14 + .../configuration/fields/RefSelectField.tsx | 5 +- .../fields/__tests__/refs.test.tsx | 102 ++++ src/components/configuration/meta/index.ts | 4 +- .../configuration/meta/providers.ts | 476 ++++++++++++++++++ .../configuration/renderer/FieldControl.tsx | 50 +- src/components/configuration/reveal.ts | 8 + src/components/configuration/review/reveal.ts | 17 +- .../sections/providers/AzureGroupDrawer.tsx | 194 +++++++ .../sections/providers/AzureGroups.tsx | 89 ++++ .../sections/providers/GlobalNotice.tsx | 32 ++ .../sections/providers/NetworkAccess.tsx | 139 +++++ .../sections/providers/ProviderCard.tsx | 196 ++++++++ .../sections/providers/ProviderGroup.tsx | 175 +++++++ .../sections/providers/ProviderSection.tsx | 131 ----- .../sections/providers/ProviderStatus.tsx | 80 +++ .../sections/providers/ProvidersRenderer.tsx | 117 ++--- .../sections/providers/SetBadge.tsx | 23 + .../sections/providers/StaleOverride.tsx | 32 ++ .../__tests__/ProvidersRenderer.test.tsx | 269 ++++++---- .../providers/__tests__/helpers.test.ts | 100 ++++ .../providers/__tests__/layout.test.ts | 205 ++++++++ .../providers/__tests__/secrets.test.tsx | 98 ++++ .../configuration/sections/providers/azure.ts | 71 +++ .../configuration/sections/providers/index.ts | 7 +- .../sections/providers/layout.ts | 234 +++++++++ .../sections/providers/network.ts | 31 ++ .../sections/providers/status.ts | 39 ++ .../configuration/values/ProvenanceChip.tsx | 44 +- .../configuration/values/values.test.tsx | 6 +- src/constants/index.ts | 1 + src/constants/providers.ts | 32 ++ src/contexts/GlobalRevealContext.ts | 8 + src/hooks/config/accordion.test.tsx | 75 +++ src/hooks/config/accordion.ts | 89 ++++ src/hooks/config/field.test.tsx | 8 - src/hooks/config/index.ts | 1 + src/hooks/config/options.test.tsx | 194 +++++++ src/hooks/config/options.ts | 235 ++++++++- src/hooks/config/sections.ts | 11 +- src/locales/en/translation.json | 224 +++++++++ src/server/config.test.ts | 2 +- src/server/config/normalize.test.ts | 101 ++++ src/server/config/normalize.ts | 81 +-- src/server/endpoints.test.ts | 105 ++++ src/server/endpoints.ts | 140 ++++++ src/server/index.ts | 1 + src/styles.css | 311 ++++++++++++ src/types/config.ts | 2 + src/types/controls.ts | 8 + src/types/fields.ts | 8 + src/types/index.ts | 1 + src/types/providers.ts | 224 +++++++++ 56 files changed, 4644 insertions(+), 383 deletions(-) create mode 100644 e2e/providers.spec.ts create mode 100644 src/components/configuration/fields/RefListField.tsx create mode 100644 src/components/configuration/fields/__tests__/refs.test.tsx create mode 100644 src/components/configuration/meta/providers.ts create mode 100644 src/components/configuration/sections/providers/AzureGroupDrawer.tsx create mode 100644 src/components/configuration/sections/providers/AzureGroups.tsx create mode 100644 src/components/configuration/sections/providers/GlobalNotice.tsx create mode 100644 src/components/configuration/sections/providers/NetworkAccess.tsx create mode 100644 src/components/configuration/sections/providers/ProviderCard.tsx create mode 100644 src/components/configuration/sections/providers/ProviderGroup.tsx delete mode 100644 src/components/configuration/sections/providers/ProviderSection.tsx create mode 100644 src/components/configuration/sections/providers/ProviderStatus.tsx create mode 100644 src/components/configuration/sections/providers/SetBadge.tsx create mode 100644 src/components/configuration/sections/providers/StaleOverride.tsx create mode 100644 src/components/configuration/sections/providers/__tests__/helpers.test.ts create mode 100644 src/components/configuration/sections/providers/__tests__/layout.test.ts create mode 100644 src/components/configuration/sections/providers/__tests__/secrets.test.tsx create mode 100644 src/components/configuration/sections/providers/azure.ts create mode 100644 src/components/configuration/sections/providers/layout.ts create mode 100644 src/components/configuration/sections/providers/network.ts create mode 100644 src/components/configuration/sections/providers/status.ts create mode 100644 src/constants/providers.ts create mode 100644 src/contexts/GlobalRevealContext.ts create mode 100644 src/hooks/config/accordion.test.tsx create mode 100644 src/hooks/config/accordion.ts create mode 100644 src/hooks/config/options.test.tsx create mode 100644 src/server/config/normalize.test.ts create mode 100644 src/server/endpoints.test.ts create mode 100644 src/server/endpoints.ts create mode 100644 src/types/providers.ts diff --git a/e2e/providers.spec.ts b/e2e/providers.spec.ts new file mode 100644 index 00000000..91eec079 --- /dev/null +++ b/e2e/providers.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON } from './live'; + +/** + * AI providers editor (WS9) journeys. They need a live LibreChat backend + * whose librechat.yaml defines an `endpoints.all` block that sets activity + * labels, Azure OpenAI groups, and no Anthropic model list: run with + * E2E_LIVE=1 against such a stack. Set E2E_DISABLED_PROVIDER to a provider + * the server does not enable (default `assistants`). + */ +test.skip(!LIVE, LIVE_SKIP_REASON); +test.describe.configure({ mode: 'serial', timeout: 120_000 }); + +const DISABLED_PROVIDER = process.env.E2E_DISABLED_PROVIDER ?? 'assistants'; + +async function openProviders(page: Page) { + await page.goto('/configuration?tab=providers'); + await expect(page.locator('form[aria-label]')).toBeVisible({ timeout: 30_000 }); + await page.waitForLoadState('networkidle'); +} + +/** The provider card, opened. */ +async function provider(page: Page, key: string): Promise { + const card = page.locator(`[id="section-endpoints.${key}"]`); + const trigger = card.locator('.settings-section-trigger').first(); + if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click(); + return card; +} + +/** A purpose group of a provider card, opened. */ +async function group(card: Locator, providerKey: string, key: string): Promise { + const section = card.locator(`[id="section-endpoints.${providerKey}-${key}"]`); + const trigger = section.locator('button[aria-expanded]').first(); + if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click(); + return section; +} + +async function reviewAndSave(page: Page) { + await page + .getByRole('region', { name: 'Unsaved changes' }) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0, { timeout: 30_000 }); +} + +test('Global overrides come first and the tab lists Network access', async ({ page }) => { + await openProviders(page); + const cards = page.locator('section[data-settings-section][id^="section-endpoints."]'); + await expect(cards.first()).toHaveAttribute('id', 'section-endpoints.all'); + await expect(cards.last()).toHaveAttribute('id', 'section-endpoints.allowedAddresses'); + await expect(page.locator('[data-toc-id="section-endpoints.allowedAddresses"]')).toHaveText( + 'Network access', + ); +}); + +test('a provider the server does not enable says so in its header', async ({ page }) => { + await openProviders(page); + const header = page.locator(`[id="section-endpoints.${DISABLED_PROVIDER}"] .settings-heading`); + await expect(header.getByText('Not enabled')).toBeVisible(); +}); + +test('a provider title model shows that Global overrides win (D11)', async ({ page }) => { + await openProviders(page); + const titles = await group(await provider(page, 'openAI'), 'openAI', 'titles'); + await expect(titles.locator('.settings-banner')).toContainText( + 'LibreChat reads title settings only from them', + ); + const model = page.locator('#endpoints-openAI-titleModel'); + await model.fill('gpt-e2e-title'); + await model.press('Tab'); + await expect(titles.getByText(/^Overridden by Global/).first()).toBeVisible(); + await page.getByRole('button', { name: /^Discard$/ }).click(); +}); + +test('Azure groups are read-only, with details in a drawer', async ({ page }) => { + await openProviders(page); + const card = await provider(page, 'azureOpenAI'); + await expect(card.getByText(/Azure groups are managed in librechat.yaml/)).toBeVisible(); + const details = card.getByRole('button', { name: /^Details of Azure group / }).first(); + await details.click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByText('Read-only. Change this group in librechat.yaml.')).toBeVisible(); + await expect(drawer.getByRole('textbox')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(drawer).toHaveCount(0); + await expect(details).toBeFocused(); +}); + +test('the Anthropic model list never ends up empty', async ({ page }) => { + await openProviders(page); + const models = await group(await provider(page, 'anthropic'), 'anthropic', 'models'); + await expect(models.getByRole('button', { name: 'Use provider defaults' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + await models.getByRole('button', { name: 'Only these models' }).click(); + const input = models.getByPlaceholder(/Add models/); + await input.fill('claude-e2e-model'); + await input.press('Enter'); + await reviewAndSave(page); + await expect(models.getByText('claude-e2e-model')).toBeVisible(); + + await models.getByRole('button', { name: 'Remove claude-e2e-model' }).click(); + await expect(models.getByText(/default model list is used/)).toBeVisible(); + await reviewAndSave(page); + await expect(models.getByRole('button', { name: 'Start from defaults' })).toBeVisible({ + timeout: 30_000, + }); +}); + +test('Network access checks each address as it is typed', async ({ page }) => { + await openProviders(page); + const card = await provider(page, 'allowedAddresses'); + await card.getByRole('button', { name: /Add address/ }).click(); + const input = card.getByPlaceholder('host:port, e.g. localhost:11434'); + await input.fill('localhost'); + await input.press('Enter'); + await expect(card.getByRole('alert')).toHaveText('Add a port, for example localhost:11434.'); + await input.fill('http://localhost:11434'); + await input.press('Enter'); + await expect(card.getByRole('alert')).toHaveText( + 'Enter only host:port, without http:// or a path.', + ); +}); diff --git a/src/components/configuration/fields/ListField.tsx b/src/components/configuration/fields/ListField.tsx index a3ef18ba..55d2b7a9 100644 --- a/src/components/configuration/fields/ListField.tsx +++ b/src/components/configuration/fields/ListField.tsx @@ -33,6 +33,7 @@ export function ListField({ multiline, keepsWhitespace, width, + validateItem, 'aria-label': ariaLabel, }: t.ListFieldProps) { const localize = useLocalize(); @@ -66,7 +67,7 @@ export function ListField({ const duplicateError = (value: string, index: number) => values.some((existing, i) => i !== index && existing === value) ? localize('com_config_ctl_list_duplicate') - : null; + : (validateItem?.(value) ?? null); const removeAt = (index: number) => emit(values.filter((_, i) => i !== index)); const replaceAt = (index: number, value: string) => { if (isBlank(value)) { @@ -146,6 +147,7 @@ export function ListField({ label={label} isBlank={isBlank} isDuplicate={(value) => values.includes(value)} + validate={validateItem} onCommit={appendDraft} onCancel={() => setAdding(false)} onContinue={(value) => { @@ -211,6 +213,7 @@ function DraftInput({ label, isBlank, isDuplicate, + validate, onCommit, onCancel, onContinue, @@ -220,6 +223,7 @@ function DraftInput({ label: string; isBlank: (value: string) => boolean; isDuplicate: (value: string) => boolean; + validate?: (value: string) => string | null; onCommit: (value: string) => void; onCancel: () => void; onContinue: (value: string) => void; @@ -235,7 +239,9 @@ function DraftInput({ setError(localize('com_config_ctl_list_duplicate')); return false; } - return true; + const problem = validate?.(next) ?? null; + setError(problem); + return problem == null; }; return ( diff --git a/src/components/configuration/fields/ModelListField.tsx b/src/components/configuration/fields/ModelListField.tsx index 93440d70..bf0cc9c7 100644 --- a/src/components/configuration/fields/ModelListField.tsx +++ b/src/components/configuration/fields/ModelListField.tsx @@ -2,9 +2,9 @@ import { useId, useState } from 'react'; import { ButtonGroup, TextField as CUITextField } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { AddItemButton, DecorativeIcon } from '@/components/shared'; +import { useLocalize, useModelListDefaults } from '@/hooks'; import { TextField } from './TextField'; import { FieldNotice } from './Notice'; -import { useLocalize } from '@/hooks'; const SEPARATORS = /[,\n]/; @@ -59,7 +59,10 @@ type Mode = 'defaults' | 'only'; export function ModelListField({ id, value, - defaults, + defaults: givenDefaults, + path, + source, + namesOnly, onChange, onClear, disabled, @@ -68,7 +71,12 @@ export function ModelListField({ }: t.ModelListFieldProps) { const localize = useLocalize(); const inputId = useId(); + const listId = useId(); + const fetched = useModelListDefaults(path ?? '', givenDefaults ? undefined : source); + const defaults = givenDefaults ?? fetched.defaults; const items = toModelItems(value); + const itemNames = new Set(items.map(modelName)); + const suggestions = fetched.suggestions.filter((name) => !itemNames.has(name)); const hasList = Array.isArray(value); const [pendingOnly, setPendingOnly] = useState(false); const [draft, setDraft] = useState(''); @@ -233,21 +241,24 @@ export function ModelListField({ onChange={(name) => updateAt(editing, { name: name.trim() })} aria-label={localize('com_config_ctl_models_name')} /> - updateAt(editing, { description })} - onClear={() => updateAt(editing, { description: '' })} - placeholder={localize('com_config_ctl_models_description')} - aria-label={localize('com_config_ctl_models_description')} - /> + {!namesOnly && ( + updateAt(editing, { description })} + onClear={() => updateAt(editing, { description: '' })} + placeholder={localize('com_config_ctl_models_description')} + aria-label={localize('com_config_ctl_models_description')} + /> + )} )} {!disabled && (
0 ? listId : undefined} value={draft} onChange={setDraft} onBlur={() => addNames(draft)} @@ -265,6 +276,13 @@ export function ModelListField({ placeholder={localize('com_config_ctl_models_add')} aria-label={localize('com_config_ctl_models_add')} /> + {suggestions.length > 0 && ( + + {suggestions.map((name) => ( + + )}
)} {items.length === 0 && ( diff --git a/src/components/configuration/fields/RefListField.tsx b/src/components/configuration/fields/RefListField.tsx new file mode 100644 index 00000000..6b753590 --- /dev/null +++ b/src/components/configuration/fields/RefListField.tsx @@ -0,0 +1,14 @@ +import type * as t from '@/types'; +import { useOptionSources } from '@/hooks'; +import { ListField } from './ListField'; + +/** + * A list of references to other configured things (`agents.allowedProviders` + * → endpoints, a spec's MCP servers): items are picked from + * `useOptionSources`, so a typo cannot slip in. Values the source does not + * know are kept and shown as they are. + */ +export function RefListField({ source, ...props }: t.RefListFieldProps) { + const { options } = useOptionSources(source); + return ; +} diff --git a/src/components/configuration/fields/RefSelectField.tsx b/src/components/configuration/fields/RefSelectField.tsx index 0b8a5763..15e1f7c8 100644 --- a/src/components/configuration/fields/RefSelectField.tsx +++ b/src/components/configuration/fields/RefSelectField.tsx @@ -1,6 +1,6 @@ import { useId } from 'react'; import type * as t from '@/types'; -import { useLocalize, useOptionSources } from '@/hooks'; +import { useLocalize, useModelSource, useOptionSources } from '@/hooks'; import { TextField } from './TextField'; /** @@ -12,6 +12,7 @@ export function RefSelectField({ id, value, source, + path, extraOptions, onChange, onClear, @@ -23,7 +24,7 @@ export function RefSelectField({ const localize = useLocalize(); const listId = useId(); const warningId = useId(); - const { options, isLoading } = useOptionSources(source); + const { options, isLoading } = useOptionSources(useModelSource(source, path)); const allOptions = extraOptions ? [...extraOptions, ...options] : options; const isUnknown = !isLoading && diff --git a/src/components/configuration/fields/__tests__/refs.test.tsx b/src/components/configuration/fields/__tests__/refs.test.tsx new file mode 100644 index 00000000..b4927de1 --- /dev/null +++ b/src/components/configuration/fields/__tests__/refs.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; +import { ModelListField } from '../ModelListField'; +import { RefListField } from '../RefListField'; +import { ListField } from '../ListField'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string) => key; + return { default: () => localize, useLocalize: () => localize }; +}); + +const values: t.ConfigValuesData = { + configValues: { endpoints: { custom: [{ name: 'Moonshot' }] } }, + editedValues: {}, +}; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient(); + client.setQueryData(['endpointModels', 0], { anthropic: ['claude-a', 'claude-b'] }); + return ( + + {children} + + ); +} + +describe('ListField validateItem', () => { + it('keeps an invalid new item out of the list and says why', () => { + const onChange = vi.fn(); + render( + (value.includes(':') ? null : 'needs a port')} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /com_ui_add_item/ })); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'localhost' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(screen.getByRole('alert').textContent).toBe('needs a port'); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: 'localhost:1' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith(['localhost:1']); + }); +}); + +describe('RefListField', () => { + it('picks list items from the option source', () => { + render( + , + { wrapper }, + ); + expect(screen.getByRole('list')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /com_ui_add_item/ })); + expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0); + }); +}); + +describe('ModelListField with a model source', () => { + it("shows the provider's models as defaults while the list is unset", () => { + render( + , + { wrapper }, + ); + expect(screen.getByText('claude-a')).toBeTruthy(); + expect(screen.getByText('com_config_ctl_models_start_from_defaults')).toBeTruthy(); + }); + + it('offers the models not yet listed as suggestions', () => { + const { container } = render( + , + { wrapper }, + ); + const options = [...container.querySelectorAll('datalist option')].map((o) => + o.getAttribute('value'), + ); + expect(options).toEqual(['claude-b']); + }); +}); diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index a98f4c4d..8035ad11 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -1,10 +1,11 @@ import type * as t from '@/types'; import { buildFieldMetaRegistry, resolveFieldMeta } from './registry'; +import { PROVIDER_FIELD_META } from './providers'; import { SECRET_CONFIG_KEYS } from '@/constants'; import { GENERIC_FIELD_META } from './generic'; /** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */ -const META_SOURCES: t.FieldMetaRegistry[] = [GENERIC_FIELD_META]; +const META_SOURCES: t.FieldMetaRegistry[] = [GENERIC_FIELD_META, PROVIDER_FIELD_META]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); @@ -93,3 +94,4 @@ function lastNamedSegment(path: string): string { } export { buildFieldMetaRegistry, resolveFieldMeta }; +export { PROVIDER_LAYOUTS, DEFAULT_PROVIDER_LAYOUT } from './providers'; diff --git a/src/components/configuration/meta/providers.ts b/src/components/configuration/meta/providers.ts new file mode 100644 index 00000000..ed8c0123 --- /dev/null +++ b/src/components/configuration/meta/providers.ts @@ -0,0 +1,476 @@ +import type * as t from '@/types'; +import { BUILT_IN_PROVIDERS, GLOBAL_PROVIDER_KEY } from '@/constants'; +import { GENERIC_FIELD_META } from './generic'; + +/** Settings shared by every endpoint, in the order admins think about them. */ +const TITLE_FIELDS = [ + 'titleConvo', + 'titleModel', + 'titleMethod', + 'titleEndpoint', + 'titlePrompt', + 'titlePromptTemplate', + 'titleTiming', +]; + +const ACTIVITY_FIELDS = [ + 'activityLabel', + 'activityModel', + 'activityEndpoint', + 'activityPrompt', + 'activityMaxPerRun', + 'activityCharLimit', + 'activityPhaseLabel', + 'activityPhaseModel', + 'activityPhaseEndpoint', + 'activityPhasePrompt', + 'activityPhaseMaxPerRun', +]; + +const REASONING_FIELDS = [ + 'reasoningLabel', + 'reasoningLabelModel', + 'reasoningLabelEndpoint', + 'reasoningLabelPrompt', + 'reasoningLabelMinChars', + 'reasoningLabelUpdateChars', + 'reasoningLabelUpdateIntervalMs', + 'reasoningLabelMaxPerRun', +]; + +const TITLES: t.ProviderGroupDef = { + key: 'titles', + labelKey: 'com_config_providers_group_titles', + descriptionKey: 'com_config_providers_group_titles_desc', + fields: TITLE_FIELDS, +}; + +const ACTIVITY: t.ProviderGroupDef = { + key: 'activity', + labelKey: 'com_config_providers_group_activity', + descriptionKey: 'com_config_providers_group_activity_desc', + fields: ACTIVITY_FIELDS, +}; + +const REASONING: t.ProviderGroupDef = { + key: 'reasoning', + labelKey: 'com_config_providers_group_reasoning', + descriptionKey: 'com_config_providers_group_reasoning_desc', + fields: REASONING_FIELDS, +}; + +const STREAMING: t.ProviderGroupDef = { + key: 'streaming', + labelKey: 'com_config_providers_group_streaming', + descriptionKey: 'com_config_providers_group_streaming_desc', + fields: ['streamRate', 'maxToolResultChars'], +}; + +const ADVANCED: t.ProviderGroupDef = { + key: 'advanced', + labelKey: 'com_config_providers_group_advanced', + descriptionKey: 'com_config_providers_group_advanced_desc', + fields: ['headers'], +}; + +/** Built-in providers take their base URL (and Assistants its key and models) from the environment. */ +function unused(fields: string[]): t.ProviderGroupDef { + return { + key: 'unused', + labelKey: 'com_config_providers_group_unused', + descriptionKey: 'com_config_providers_group_unused_desc', + fields, + unused: true, + }; +} + +const LABEL_GROUPS = [TITLES, ACTIVITY, REASONING]; +const STANDARD_TAIL = [...LABEL_GROUPS, STREAMING, ADVANCED]; + +const ASSISTANTS_LAYOUT: t.ProviderGroupDef[] = [ + { + key: 'assistants', + labelKey: 'com_config_providers_group_assistants', + descriptionKey: 'com_config_providers_group_assistants_desc', + fields: [ + 'capabilities', + 'disableBuilder', + 'privateAssistants', + 'retrievalModels', + 'supportedIds', + 'excludedIds', + 'version', + ], + expanded: true, + pairs: [ + ['disableBuilder', 'privateAssistants'], + ['supportedIds', 'excludedIds'], + ], + }, + ...LABEL_GROUPS, + { ...STREAMING, fields: ['streamRate', 'pollIntervalMs', 'timeoutMs', 'maxToolResultChars'] }, + ADVANCED, + unused(['apiKey', 'baseURL', 'models']), +]; + +/** + * Purpose groups of each provider card (AP-4), most important first. Child + * keys a layout leaves out (a newer schema) fall into Advanced, so the tab + * stays schema-driven; `layouts.test.ts` lists them so they get a home. + */ +export const PROVIDER_LAYOUTS: Readonly> = { + [GLOBAL_PROVIDER_KEY]: STANDARD_TAIL, + openAI: [...STANDARD_TAIL, unused(['baseURL'])], + google: [...STANDARD_TAIL, unused(['baseURL'])], + anthropic: [ + { + key: 'models', + labelKey: 'com_config_providers_group_models', + fields: ['models', 'vertex'], + expanded: true, + }, + ...STANDARD_TAIL, + unused(['baseURL']), + ], + azureOpenAI: [ + { + key: 'groups', + labelKey: 'com_config_providers_group_azure', + descriptionKey: 'com_config_providers_group_azure_desc', + fields: ['groups', 'assistants'], + expanded: true, + }, + ...LABEL_GROUPS, + STREAMING, + ADVANCED, + ], + azureAssistants: ASSISTANTS_LAYOUT, + assistants: ASSISTANTS_LAYOUT, + agents: [ + { + key: 'features', + labelKey: 'com_config_providers_group_features', + descriptionKey: 'com_config_providers_group_features_desc', + fields: [ + 'capabilities', + 'disableBuilder', + 'allowedProviders', + 'recursionLimit', + 'maxRecursionLimit', + ], + expanded: true, + pairs: [ + ['disableBuilder', 'allowedProviders'], + ['recursionLimit', 'maxRecursionLimit'], + ], + }, + ...LABEL_GROUPS, + { + key: 'citations', + labelKey: 'com_config_providers_group_citations', + descriptionKey: 'com_config_providers_group_citations_desc', + fields: ['maxCitations', 'maxCitationsPerFile', 'minRelevanceScore'], + }, + { + key: 'code', + labelKey: 'com_config_providers_group_code', + descriptionKey: 'com_config_providers_group_code_desc', + fields: [ + 'codeApiUploadConcurrency', + 'codeApiMaxRetryWaitMs', + 'repositoryInstructions', + 'fileSharing', + 'statefulCodeSessions', + ], + }, + { + key: 'background', + labelKey: 'com_config_providers_group_background', + descriptionKey: 'com_config_providers_group_background_desc', + fields: ['backgroundTasks', 'eventDriven'], + }, + { + key: 'approval', + labelKey: 'com_config_providers_group_approval', + descriptionKey: 'com_config_providers_group_approval_desc', + fields: ['toolApproval', 'askUserQuestion'], + }, + { + key: 'apis', + labelKey: 'com_config_providers_group_apis', + descriptionKey: 'com_config_providers_group_apis_desc', + fields: ['managementApi', 'remoteApi'], + }, + { + key: 'limits', + labelKey: 'com_config_providers_group_limits', + descriptionKey: 'com_config_providers_group_limits_desc', + fields: [ + 'maxSubagents', + 'streamRate', + 'maxToolResultChars', + 'maxProviderErrorChars', + 'modelResponseHeadersTimeoutMs', + 'modelResponseBodyTimeoutMs', + 'maxToolCallArgBytes', + 'maxToolCallArgBytesByTool', + 'maxDeltaEventsPerTurn', + 'maxRetainedToolCountChars', + 'skills', + ], + }, + { + key: 'checkpointer', + labelKey: 'com_config_providers_group_checkpointer', + descriptionKey: 'com_config_providers_group_checkpointer_desc', + fields: ['checkpointer'], + }, + ADVANCED, + ], + bedrock: [ + { + key: 'models', + labelKey: 'com_config_providers_group_models', + descriptionKey: 'com_config_providers_group_models_bedrock_desc', + fields: ['models', 'availableRegions', 'inferenceProfiles'], + expanded: true, + }, + { + key: 'guardrails', + labelKey: 'com_config_providers_group_guardrails', + descriptionKey: 'com_config_providers_group_guardrails_desc', + fields: ['guardrailConfig'], + }, + ...STANDARD_TAIL, + unused(['baseURL']), + ], +}; + +/** The layout of a provider the panel does not know yet: the shared endpoint groups. */ +export const DEFAULT_PROVIDER_LAYOUT: readonly t.ProviderGroupDef[] = STANDARD_TAIL; + +/** Agent capabilities with plain-language labels and one-line descriptions (AP-6). */ +const AGENT_CAPABILITIES = [ + 'execute_code', + 'file_search', + 'web_search', + 'artifacts', + 'actions', + 'tools', + 'context', + 'ocr', + 'memory', + 'skills', + 'subagents', + 'ask_user_question', + 'deferred_tools', + 'run_in_background', + 'tool_intents', + 'stateful_code_sessions', + 'programmatic_tools', + 'end_after_tools', + 'hide_sequential_outputs', + 'chain', +]; + +const ASSISTANT_CAPABILITIES = [ + 'code_interpreter', + 'image_vision', + 'retrieval', + 'actions', + 'tools', +]; + +/** Labels `com_config_providers__` and descriptions `…_desc` for a capability list. */ +function capabilityMeta(kind: 'cap' | 'acap', values: string[]): t.FieldMeta { + const key = (value: string) => `com_config_providers_${kind}_${value}`; + return { + control: 'multi-enum', + order: values, + enumLabels: Object.fromEntries(values.map((value) => [value, key(value)])), + enumDescriptions: Object.fromEntries(values.map((value) => [value, `${key(value)}_desc`])), + }; +} + +/** `*Model` fields of a provider suggest that provider's models (AP-9). */ +const MODEL_REFERENCE_FIELDS = [ + 'titleModel', + 'activityModel', + 'activityPhaseModel', + 'reasoningLabelModel', +]; + +/** Providers whose own models the title, activity and reasoning models default to. */ +const MODEL_PROVIDERS = BUILT_IN_PROVIDERS.filter( + (provider) => + provider !== 'agents' && provider !== 'assistants' && provider !== 'azureAssistants', +); + +function modelReferenceMeta(): t.FieldMetaRegistry { + /** Global's model fields suggest the models of the endpoint set beside them, if any. */ + const entries: t.FieldMetaRegistry = Object.fromEntries( + MODEL_REFERENCE_FIELDS.map((key) => [ + `endpoints.${GLOBAL_PROVIDER_KEY}.${key}`, + { optionSource: 'models:' }, + ]), + ); + for (const provider of MODEL_PROVIDERS) { + for (const key of MODEL_REFERENCE_FIELDS) { + entries[`endpoints.${provider}.${key}`] = { optionSource: `models:${provider}` }; + } + } + return entries; +} + +/** Descriptions the shared endpoint registry leaves out, keyed by path. */ +const DESCRIPTIONS: Readonly> = { + 'endpoints.{}.activityPhaseModel': 'com_config_providers_desc_activityPhaseModel', + 'endpoints.{}.activityPhaseEndpoint': 'com_config_providers_desc_activityPhaseEndpoint', + 'endpoints.{}.activityPhasePrompt': 'com_config_providers_desc_activityPhasePrompt', + 'endpoints.{}.reasoningLabelEndpoint': 'com_config_providers_desc_reasoningLabelEndpoint', + 'endpoints.{}.reasoningLabelPrompt': 'com_config_providers_desc_reasoningLabelPrompt', + 'endpoints.{}.reasoningLabelMinChars': 'com_config_providers_desc_reasoningLabelMinChars', + 'endpoints.{}.reasoningLabelUpdateChars': 'com_config_providers_desc_reasoningLabelUpdateChars', + 'endpoints.{}.reasoningLabelUpdateIntervalMs': + 'com_config_providers_desc_reasoningLabelUpdateIntervalMs', + 'endpoints.{}.baseURL': 'com_config_providers_desc_unused_baseURL', + 'endpoints.assistants.apiKey': 'com_config_providers_desc_unused_apiKey', + 'endpoints.azureAssistants.apiKey': 'com_config_providers_desc_unused_apiKey', + 'endpoints.assistants.models': 'com_config_providers_desc_unused_models', + 'endpoints.azureAssistants.models': 'com_config_providers_desc_unused_models', + 'endpoints.anthropic.models': 'com_config_providers_desc_models', + 'endpoints.anthropic.vertex': 'com_config_providers_desc_vertex', + 'endpoints.bedrock.models': 'com_config_providers_desc_models', + 'endpoints.bedrock.availableRegions': 'com_config_providers_desc_availableRegions', + 'endpoints.bedrock.guardrailConfig.guardrailIdentifier': + 'com_config_providers_desc_guardrailIdentifier', + 'endpoints.bedrock.guardrailConfig.guardrailVersion': + 'com_config_providers_desc_guardrailVersion', + 'endpoints.bedrock.guardrailConfig.trace': 'com_config_providers_desc_guardrailTrace', + 'endpoints.bedrock.guardrailConfig.streamProcessingMode': + 'com_config_providers_desc_guardrailStreamMode', + 'endpoints.azureOpenAI.assistants': 'com_config_providers_desc_azureAssistants', + 'endpoints.azureAssistants.capabilities': 'com_config_desc_endpoints_assistants_capabilities', + 'endpoints.azureAssistants.excludedIds': 'com_config_desc_endpoints_assistants_excludedIds', + 'endpoints.azureAssistants.pollIntervalMs': 'com_config_desc_endpoints_assistants_pollIntervalMs', + 'endpoints.azureAssistants.privateAssistants': + 'com_config_desc_endpoints_assistants_privateAssistants', + 'endpoints.azureAssistants.retrievalModels': + 'com_config_desc_endpoints_assistants_retrievalModels', + 'endpoints.azureAssistants.supportedIds': 'com_config_desc_endpoints_assistants_supportedIds', + 'endpoints.azureAssistants.timeoutMs': 'com_config_desc_endpoints_assistants_timeoutMs', + 'endpoints.assistants.disableBuilder': 'com_config_providers_desc_assistants_disableBuilder', + 'endpoints.azureAssistants.disableBuilder': 'com_config_providers_desc_assistants_disableBuilder', + 'endpoints.agents.maxToolCallArgBytes': 'com_config_providers_desc_agents_maxToolCallArgBytes', + 'endpoints.agents.maxToolCallArgBytesByTool': + 'com_config_providers_desc_agents_maxToolCallArgBytesByTool', + 'endpoints.agents.maxDeltaEventsPerTurn': + 'com_config_providers_desc_agents_maxDeltaEventsPerTurn', + 'endpoints.agents.maxRetainedToolCountChars': + 'com_config_providers_desc_agents_maxRetainedToolCountChars', + 'endpoints.agents.codeApiUploadConcurrency': + 'com_config_providers_desc_agents_codeApiUploadConcurrency', + 'endpoints.agents.codeApiMaxRetryWaitMs': + 'com_config_providers_desc_agents_codeApiMaxRetryWaitMs', + 'endpoints.agents.repositoryInstructions.timeoutMs': + 'com_config_providers_desc_agents_repositoryInstructions_timeoutMs', + 'endpoints.agents.fileSharing.enabled': 'com_config_providers_desc_agents_fileSharing_enabled', + 'endpoints.agents.fileSharing.allowSiblingSharing': + 'com_config_providers_desc_agents_fileSharing_allowSiblingSharing', + 'endpoints.agents.fileSharing.maxFiles': 'com_config_providers_desc_agents_fileSharing_maxFiles', + 'endpoints.agents.fileSharing.maxPrivateBytes': + 'com_config_providers_desc_agents_fileSharing_maxPrivateBytes', + 'endpoints.agents.fileSharing.ttlMs': 'com_config_providers_desc_agents_fileSharing_ttlMs', + 'endpoints.agents.eventDriven.selfUrl': 'com_config_providers_desc_agents_eventDriven_selfUrl', + 'endpoints.agents.backgroundTasks.completionWakeups': + 'com_config_providers_desc_agents_backgroundTasks_completionWakeups', + 'endpoints.agents.backgroundTasks.completionResultMaxChars': + 'com_config_providers_desc_agents_backgroundTasks_completionResultMaxChars', + 'endpoints.agents.backgroundTasks.completionResultBatchSize': + 'com_config_providers_desc_agents_backgroundTasks_completionResultBatchSize', + 'endpoints.agents.backgroundTasks.ordinaryToolCancellation': + 'com_config_providers_desc_agents_backgroundTasks_ordinaryToolCancellation', + 'endpoints.agents.skills.maxCatalogSkills': + 'com_config_providers_desc_agents_skills_maxCatalogSkills', + 'endpoints.agents.askUserQuestion.retainedAnswers.enabled': + 'com_config_providers_desc_agents_retainedAnswers_enabled', + 'endpoints.agents.askUserQuestion.retainedAnswers.maxTokens': + 'com_config_providers_desc_agents_retainedAnswers_maxTokens', + 'endpoints.agents.checkpointer.type': 'com_config_providers_desc_agents_checkpointer_type', + 'endpoints.agents.checkpointer.ttl': 'com_config_providers_desc_agents_checkpointer_ttl', + 'endpoints.agents.checkpointer.checkpointCollectionName': + 'com_config_providers_desc_agents_checkpointer_collection', + 'endpoints.agents.checkpointer.checkpointWritesCollectionName': + 'com_config_providers_desc_agents_checkpointer_collection', +}; + +function descriptionMeta(): t.FieldMetaRegistry { + return Object.fromEntries( + Object.entries(DESCRIPTIONS).map(([path, descriptionKey]) => [path, { descriptionKey }]), + ); +} + +const PROVIDER_FIELD = /^endpoints\.([^.{}[\]]+)\.(.+)$/; + +/** + * The registry resolves a path to its most literal entry without merging + * wildcard entries in, so a provider-specific entry + * (`endpoints.openAI.titleModel`) starts from what `endpoints.{}.titleModel` + * sets (shared descriptions and runtime defaults). + */ +function withShared( + registry: t.FieldMetaRegistry, + shared: t.FieldMetaRegistry, +): t.FieldMetaRegistry { + const result: t.FieldMetaRegistry = {}; + for (const [path, meta] of Object.entries(registry)) { + const match = PROVIDER_FIELD.exec(path); + const wildcard = match ? `endpoints.{}.${match[2]}` : undefined; + result[path] = wildcard ? { ...shared[wildcard], ...meta } : meta; + } + return result; +} + +/** Pins each provider's purpose groups into the registry, so field order follows them. */ +function layoutMeta(): t.FieldMetaRegistry { + return Object.fromEntries( + Object.entries(PROVIDER_LAYOUTS).map(([provider, groups]) => [ + `endpoints.${provider}`, + { groups: groups.map(({ key, fields }) => ({ key, fields })) }, + ]), + ); +} + +const STATIC_META: t.FieldMetaRegistry = { + [`endpoints.${GLOBAL_PROVIDER_KEY}`]: { labelKey: 'com_config_providers_global_title' }, + 'endpoints.anthropic.models': { control: 'model-list', optionSource: 'models:anthropic' }, + 'endpoints.bedrock.models': { control: 'model-list', optionSource: 'models:bedrock' }, + 'endpoints.agents.capabilities': capabilityMeta('cap', AGENT_CAPABILITIES), + 'endpoints.assistants.capabilities': capabilityMeta('acap', ASSISTANT_CAPABILITIES), + 'endpoints.azureAssistants.capabilities': capabilityMeta('acap', ASSISTANT_CAPABILITIES), + 'endpoints.allowedAddresses': { + labelKey: 'com_config_providers_network_addresses', + descriptionKey: 'com_config_providers_network_addresses_desc', + }, + 'endpoints.agents.checkpointer.ttl': { unit: 's' }, + 'endpoints.agents.fileSharing.maxPrivateBytes': { width: 'M' }, +}; + +/** + * AI providers metadata (WS9): purpose groups per provider, model list and + * capability controls, model suggestions, labels and descriptions. Merged + * after the generic registry (`META_SOURCES`), so these entries win. + */ +export const PROVIDER_FIELD_META: t.FieldMetaRegistry = (() => { + const merged: t.FieldMetaRegistry = {}; + for (const registry of [layoutMeta(), modelReferenceMeta(), descriptionMeta(), STATIC_META]) { + for (const [path, meta] of Object.entries(registry)) { + merged[path] = { ...merged[path], ...meta }; + } + } + const shared: t.FieldMetaRegistry = {}; + for (const [path, meta] of Object.entries(merged)) { + if (path.startsWith('endpoints.{}.')) shared[path] = { ...GENERIC_FIELD_META[path], ...meta }; + } + return withShared(merged, { ...GENERIC_FIELD_META, ...shared }); +})(); diff --git a/src/components/configuration/renderer/FieldControl.tsx b/src/components/configuration/renderer/FieldControl.tsx index 443d624d..ef34b1fa 100644 --- a/src/components/configuration/renderer/FieldControl.tsx +++ b/src/components/configuration/renderer/FieldControl.tsx @@ -27,10 +27,10 @@ import { RefSelectField } from '../fields/RefSelectField'; import { TriStateField } from '../fields/TriStateField'; import { TextareaField } from '../fields/TextareaField'; import { KeyValueField } from '../fields/KeyValueField'; +import { RefListField } from '../fields/RefListField'; import { LiteralField } from '../fields/LiteralField'; -import { useLocalize, useFieldError } from '@/hooks'; -import { labelForPath } from '../meta'; import { isWhitespaceSignificantPath } from '@/utils'; +import { useLocalize, useFieldError } from '@/hooks'; import { NumberField } from '../fields/NumberField'; import { SelectField } from '../fields/SelectField'; import { ToggleField } from '../fields/ToggleField'; @@ -39,6 +39,7 @@ import { TextField } from '../fields/TextField'; import { ListField } from '../fields/ListField'; import { CodeField } from '../fields/CodeField'; import { toKVPair } from '../fields/kv'; +import { labelForPath } from '../meta'; /** "Global: …" for a value `endpoints.all` sets, "Inherited: …" for Base's in a profile, else "Default: …". */ function placeholderKey(fieldDefault: t.FieldDefault): string { @@ -125,6 +126,28 @@ function groupName(path: string, label: string, localize: t.LocalizeFn): string }); } +/** + * Options of a checkbox grid with their one-line descriptions + * (`FieldMeta.enumDescriptions`), in `FieldMeta.order` when it lists them. + */ +function describedOptions( + options: t.SelectOption[], + meta: t.FieldMeta | undefined, + localize: t.LocalizeFn, +): t.MultiEnumOption[] { + const descriptionKeys = meta?.enumDescriptions; + const described = descriptionKeys + ? options.map((option) => { + const key = descriptionKeys[option.value]; + return key ? { ...option, description: localize(key) } : option; + }) + : options; + if (!meta?.order) return described; + const rank = new Map(meta.order.map((value, index) => [value, index])); + const last = meta.order.length; + return [...described].sort((a, b) => (rank.get(a.value) ?? last) - (rank.get(b.value) ?? last)); +} + /** * The control for one field, shared by full settings rows and inline entry * rows. Picks the control from `FieldMeta.control` or the schema shape, wires @@ -308,6 +331,7 @@ export function FieldControl({ {...common} value={value == null ? undefined : textValue(value)} source={optionSource} + path={path} onChange={onChange} onClear={clear ?? onClear} placeholder={placeholder} @@ -367,7 +391,11 @@ export function FieldControl({ (next === undefined ? onClear() : onChange(next))} clearOnEmpty={false} /> @@ -377,6 +405,9 @@ export function FieldControl({ + ); + } return ( (CLOSED_GRID); grid; ) { + const owner = grid.parentElement; + if (owner) changed = expandContainer(owner, tried) || changed; + grid = owner?.closest(CLOSED_GRID) ?? null; + } + } let deepest: HTMLElement | null = null; for (const prefix of prefixes) { const ancestor = findPathElement(container, prefix); diff --git a/src/components/configuration/review/reveal.ts b/src/components/configuration/review/reveal.ts index 4564db90..1a304828 100644 --- a/src/components/configuration/review/reveal.ts +++ b/src/components/configuration/review/reveal.ts @@ -59,6 +59,18 @@ function expand(element: HTMLElement): boolean { return true; } +/** Opens the collapsed groups an element is rendered in (eagerly rendered group bodies); true when any was closed. */ +function expandEnclosing(element: HTMLElement): boolean { + let opened = false; + let grid = element.closest(CLOSED_GRID); + while (grid) { + grid.parentElement?.dispatchEvent(new Event('config:expand')); + opened = true; + grid = grid.parentElement?.closest(CLOSED_GRID) ?? null; + } + return opened; +} + /** Expands every collapsed ancestor of the path that is on the page; true when anything opened. */ function expandAncestors(segments: string[]): boolean { let opened = false; @@ -92,9 +104,10 @@ export function revealConfigPath(path: string, entry?: t.RevealEntry, round = 0) const segments = path.split('.'); setTimeout( () => { - const opened = expandAncestors(segments); + const ancestorsOpened = expandAncestors(segments); const deepest = findDeepest(segments, entryFieldId(path, entry)); - const reached = deepest?.depth === segments.length; + const opened = (deepest != null && expandEnclosing(deepest.element)) || ancestorsOpened; + const reached = deepest?.depth === segments.length && !opened; const retry = (!reached && opened) || !deepest; if (retry && round < MAX_ROUNDS) { revealConfigPath(path, entry, round + 1); diff --git a/src/components/configuration/sections/providers/AzureGroupDrawer.tsx b/src/components/configuration/sections/providers/AzureGroupDrawer.tsx new file mode 100644 index 00000000..5dad3300 --- /dev/null +++ b/src/components/configuration/sections/providers/AzureGroupDrawer.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { CloseButton } from '@/components/shared'; +import { formatConfigValue } from '../../values'; +import { maskCredential } from './azure'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +function Item({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function Credential({ value }: { value: string | undefined }) { + const localize = useLocalize(); + const credential = maskCredential(value); + if (credential.kind === 'env') { + return <>{localize('com_config_providers_azure_env', { name: credential.name })}; + } + if (credential.kind === 'masked') return <>{localize('com_config_providers_azure_masked')}; + return {localize('com_config_providers_azure_none')}; +} + +/** + * Details of one Azure OpenAI group (AP-1), read-only: connection, version, + * headers and parameters, and its models with their deployments. The API + * key and header values are never shown; an environment reference is named. + */ +export function AzureGroupDrawer({ + group: openGroup, + onClose, + returnFocus, +}: t.AzureGroupDrawerProps) { + const localize = useLocalize(); + /** The last group shown, kept while the drawer animates closed. */ + const [group, setGroup] = useState(openGroup); + if (openGroup && openGroup !== group) setGroup(openGroup); + const none = {localize('com_config_providers_azure_none')}; + const yesNo = (on: boolean) => + localize(on ? 'com_config_providers_azure_yes' : 'com_config_providers_azure_no'); + const inherits = ( + {localize('com_config_providers_azure_inherits')} + ); + + return ( + !open && onClose()}> + + + { + event.preventDefault(); + returnFocus?.current?.focus(); + }} + className={cn( + 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-xl', + 'border-l border-(--cui-color-stroke-default)', + 'data-[state=closed]:animate-drawer-out data-[state=open]:animate-drawer-in', + )} + > + {group && ( + <> +
+
+ + {group.group} + + + {localize('com_config_providers_azure_drawer_desc')} + +
+ +
+
+
+ + + + + {yesNo(group.serverless)} + + {group.serverless ? ( + + {group.baseURL ?? none} + + ) : ( + + {group.instanceName ?? none} + + )} + {!group.serverless && group.baseURL && ( + + {group.baseURL} + + )} + + {group.deploymentName ?? none} + + + {group.version ?? none} + + + {yesNo(group.assistants)} + + {group.additionalHeaders && ( + +
    + {Object.entries(group.additionalHeaders).map(([name, value]) => ( +
  • + {name}: +
  • + ))} +
+
+ )} + {group.addParams && ( + +
    + {Object.entries(group.addParams).map(([name, value]) => ( +
  • + {name}:{' '} + {formatConfigValue(value, localize, name)} +
  • + ))} +
+
+ )} + {group.dropParams && group.dropParams.length > 0 && ( + + {group.dropParams.join(', ')} + + )} +
+
+

+ {localize('com_config_providers_azure_models_title')} +

+ {group.models.length === 0 ? ( +

+ {localize('com_config_providers_azure_models_empty')} +

+ ) : ( + + + + + + + + + + {group.models.map((model) => ( + + + + + + ))} + +
+ {localize('com_config_providers_azure_model_col_name')} + + {localize('com_config_providers_azure_model_col_deployment')} + + {localize('com_config_providers_azure_model_col_version')} +
+ {model.name} + {model.deploymentName ?? inherits}{model.version ?? inherits}
+ )} +
+
+ + )} +
+
+
+ ); +} diff --git a/src/components/configuration/sections/providers/AzureGroups.tsx b/src/components/configuration/sections/providers/AzureGroups.tsx new file mode 100644 index 00000000..e7980860 --- /dev/null +++ b/src/components/configuration/sections/providers/AzureGroups.tsx @@ -0,0 +1,89 @@ +import { useMemo, useRef, useState } from 'react'; +import type * as t from '@/types'; +import { AzureGroupDrawer } from './AzureGroupDrawer'; +import { toAzureGroups } from './azure'; +import { useLocalize } from '@/hooks'; + +/** + * Azure OpenAI groups as a read-only table (AP-1 phase 1, D13): Group | + * Instance or base URL | API version | Models | Serverless | Assistants, + * each with a details drawer (credentials masked). LibreChat applies Azure + * groups only from librechat.yaml, so nothing here is editable. + */ +export function AzureGroupsTable({ value }: t.AzureGroupsTableProps) { + const localize = useLocalize(); + const rows = useMemo(() => toAzureGroups(value), [value]); + const [selected, setSelected] = useState(null); + const opener = useRef(null); + const yesNo = (on: boolean) => + localize(on ? 'com_config_providers_azure_yes' : 'com_config_providers_azure_no'); + + if (rows.length === 0) { + return

{localize('com_config_providers_azure_empty')}

; + } + + return ( +
+ + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
{localize('com_config_providers_azure_col_group')} + {localize('com_config_providers_azure_col_target')} + + {localize('com_config_providers_azure_col_version')} + + {localize('com_config_providers_azure_col_models')} + + {localize('com_config_providers_azure_col_serverless')} + + {localize('com_config_providers_azure_col_assistants')} + + {localize('com_config_providers_azure_details')} +
+ {row.group} + + + {row.baseURL ?? row.instanceName ?? '—'} + + {row.version ?? '—'}{row.models.length}{yesNo(row.serverless)}{yesNo(row.assistants)} + +
+ setSelected(null)} returnFocus={opener} /> +
+ ); +} diff --git a/src/components/configuration/sections/providers/GlobalNotice.tsx b/src/components/configuration/sections/providers/GlobalNotice.tsx new file mode 100644 index 00000000..5aa15dd4 --- /dev/null +++ b/src/components/configuration/sections/providers/GlobalNotice.tsx @@ -0,0 +1,32 @@ +import { revealConfigPath } from '../../review/reveal'; +import { DecorativeIcon } from '@/components/shared'; +import { GLOBAL_PROVIDER_KEY } from '@/constants'; +import { useLocalize } from '@/hooks'; + +const GLOBAL_TITLES_PATH = `endpoints.${GLOBAL_PROVIDER_KEY}.titleConvo`; + +/** + * D11 banner in a provider's Title generation group: while Global overrides + * exist, LibreChat reads title settings only from them, so this provider's + * are ignored. Links to the Global title settings. + */ +export function GlobalTitlesNotice() { + const localize = useLocalize(); + return ( +
+ + + +

+ {localize('com_config_providers_global_titles_banner')}{' '} + +

+
+ ); +} diff --git a/src/components/configuration/sections/providers/NetworkAccess.tsx b/src/components/configuration/sections/providers/NetworkAccess.tsx new file mode 100644 index 00000000..94615a09 --- /dev/null +++ b/src/components/configuration/sections/providers/NetworkAccess.tsx @@ -0,0 +1,139 @@ +import { useContext } from 'react'; +import type * as t from '@/types'; +import { useLocalize, useFieldState, usePersistentExpanded } from '@/hooks'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { ADDRESS_ISSUE_KEYS, addressIssue } from './network'; +import { PROVIDERS_STORAGE_KEY } from './ProviderGroup'; +import { SettingsSection } from '../../SettingsSection'; +import { isConfigRecord, isRowVisible } from '@/utils'; +import { ListField } from '../../fields/ListField'; +import { ConfigRow } from '../../ConfigRow'; +import { toFieldId } from '../../renderer'; +import { labelForPath } from '../../meta'; +import { summaryValue } from './layout'; + +function isStringList(value: t.ConfigValue): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +/** + * "Network access" card for `endpoints.allowedAddresses` (AP-7): the private + * host:port services AI providers and custom endpoints may reach (LibreChat's + * SSRF allowlist). Each entry is checked as it is typed with LibreChat's own + * rules, and only the first relevant problem is shown ("Add a port"). + */ +export function NetworkAccessCard({ + field, + parentPath, + parentValue, + getValue, + onChange, + onResetField, + onDiscardField, + editedValues, + disabled, + permissions, + configuredPaths, + dbOverridePaths, + touchedPaths, + pendingResets, + previewMode, + previewScope, + previewChangedPaths, + resolvedValues, + showConfiguredOnly, +}: t.NetworkAccessCardProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const path = `${parentPath}.${field.key}`; + const state = useFieldState(path); + const { isExpanded, hasEverExpanded, sectionRef, toggle } = usePersistentExpanded({ + storageKey: PROVIDERS_STORAGE_KEY, + id: field.key, + revealPaths: [path], + }); + const parentRecord = isConfigRecord(parentValue) ? parentValue : {}; + const value = getValue(path, parentRecord[field.key]); + const values = isStringList(value) ? value : []; + const isConfigured = configuredPaths?.has(path) ?? false; + const isDbOverride = dbOverridePaths?.has(path) ?? false; + const hasPendingEdit = editedValues ? path in editedValues : false; + + if (!isRowVisible(rowFilter, path)) return null; + if (showConfiguredOnly && !isConfigured && !hasPendingEdit && !rowFilter) return null; + + /** Back to no override; a list librechat.yaml sets cannot be emptied here (D3). */ + const clear = (): boolean => { + if (isDbOverride || state.isOverride) { + onChange(path, undefined); + return true; + } + if (isConfigured) return false; + if (hasPendingEdit) onDiscardField?.(path); + return true; + }; + + const validateItem = (entry: string): string | null => { + const issue = addressIssue(entry); + return issue ? localize(ADDRESS_ISSUE_KEYS[issue]) : null; + }; + + const label = labelForPath(path, localize); + const summary = + isExpanded || values.length === 0 ? undefined : summaryValue(field, values, localize); + + return ( + + {localize('com_config_providers_network_desc')} + {summary && ( + + {localize('com_config_providers_summary_set', { summary })} + + )} + + } + level={2} + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded={hasEverExpanded} + > + + onChange(path, next)} + onClear={clear} + disabled={disabled} + placeholder={localize('com_config_providers_network_placeholder')} + itemLabel={localize('com_config_providers_network_item')} + validateItem={validateItem} + width="M" + aria-label={label} + /> + + + ); +} diff --git a/src/components/configuration/sections/providers/ProviderCard.tsx b/src/components/configuration/sections/providers/ProviderCard.tsx new file mode 100644 index 00000000..abdaf603 --- /dev/null +++ b/src/components/configuration/sections/providers/ProviderCard.tsx @@ -0,0 +1,196 @@ +import { useContext, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + isPathSet, + formatSummary, + summarizeFields, + createSummaryReader, + resolveProviderGroups, +} from './layout'; +import { useLocalize, useGlobalTitleBlock, usePersistentExpanded } from '@/hooks'; +import { getValueAtPath, isConfigRecord, getUnknownConfigEntries } from '@/utils'; +import { ProviderStatusBadge, ProviderStatusRow } from './ProviderStatus'; +import { ProviderGroup, PROVIDERS_STORAGE_KEY } from './ProviderGroup'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { countConfigured, hasPathOverlap } from '../../counts'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { UnknownSettings } from '../../UnknownSettings'; +import { SettingsSection } from '../../SettingsSection'; +import { GlobalTitlesNotice } from './GlobalNotice'; +import { GLOBAL_PROVIDER_KEY } from '@/constants'; +import { AzureGroupsTable } from './AzureGroups'; +import { StaleOverride } from './StaleOverride'; +import { InfoBanner } from '../../InfoBanner'; +import { labelForPath } from '../../meta'; +import { SetBadge } from './SetBadge'; + +const NO_PATHS: ReadonlySet = new Set(); + +/** + * One provider of the AI providers tab (AP-4): an h2 card whose header names + * the provider with its Active / Not enabled status (AP-5), how many settings + * are customized (AP-10) and, collapsed, what they are. Its body starts with + * the status and credentials row, then the purpose groups. Open state is + * remembered per tab (AP-8). The Global block explains that it applies to + * every endpoint (AP-3). + */ +export function ProviderCard({ field, parentPath, parentValue, ...rest }: t.ProviderCardProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const values = useContext(ConfigValuesContext); + const hasGlobal = useGlobalTitleBlock(); + const { getValue, configuredPaths, dbOverridePaths, editedValues, onResetField, disabled } = rest; + const path = `${parentPath}.${field.key}`; + const isGlobal = field.key === GLOBAL_PROVIDER_KEY; + const children = useMemo(() => field.children ?? [], [field.children]); + const { groups } = useMemo( + () => resolveProviderGroups(field.key, children), + [field.key, children], + ); + const { isExpanded, hasEverExpanded, sectionRef, toggle } = usePersistentExpanded({ + storageKey: PROVIDERS_STORAGE_KEY, + id: field.key, + revealPaths: [path], + }); + + const parentRecord = isConfigRecord(parentValue) ? parentValue : {}; + const providerValue = getValue(path, parentRecord[field.key]); + const { configured } = countConfigured(children, path, configuredPaths); + const overrides = dbOverridePaths + ? countConfigured(children, path, dbOverridePaths, true).configured + : 0; + const summary = isExpanded + ? undefined + : formatSummary( + summarizeFields( + groups.filter((group) => !group.def.unused).flatMap((group) => group.fields), + path, + createSummaryReader({ + providerPath: path, + providerValue, + getValue, + configuredPaths, + editedValues, + root: values?.configValues ?? null, + }), + localize, + ), + localize, + ); + + const description: ReactNode = isGlobal ? ( + <> + {localize('com_config_providers_global_desc')} + {summary && ( + + {localize('com_config_providers_summary_set', { summary })} + + )} + + ) : ( + summary + ); + + const groupContent = (group: t.ProviderGroup): Partial => { + const key = group.def.key; + if (key === 'titles' && hasGlobal && !isGlobal) { + return { + notice: , + summaryOverride: localize('com_config_providers_summary_ignored'), + }; + } + const groupSet = group.fields.some((child) => + isPathSet(`${path}.${child.key}`, configuredPaths, editedValues), + ); + if (key === 'titles' && isGlobal && !groupSet) { + return { summaryOverride: localize('com_config_providers_summary_global_titles') }; + } + if (key === 'groups' && field.key === 'azureOpenAI') { + const groupsPath = `${path}.groups`; + const groupsValue = getValue(groupsPath, getValueAtPath(providerValue, 'groups')); + const groupCount = Array.isArray(groupsValue) ? groupsValue.length : 0; + return { + group: { ...group, fields: group.fields.filter((child) => child.key !== 'groups') }, + countBadge: + groupCount > 0 ? ( + + , + {localize('com_config_providers_groups', { count: groupCount })} + + ) : undefined, + before: ( +
+ + {hasPathOverlap(groupsPath, dbOverridePaths ?? NO_PATHS) && ( + onResetField(groupsPath)} + /> + )} + +
+ ), + }; + } + if (key === 'models' && field.key === 'anthropic') { + const vertexPath = `${path}.vertex`; + const vertexSet = isConfigRecord( + getValue(vertexPath, getValueAtPath(providerValue, 'vertex')), + ); + if (vertexSet) return {}; + return { + group: { ...group, fields: group.fields.filter((child) => child.key !== 'vertex') }, + after: ( +

{localize('com_config_providers_vertex_none')}

+ ), + }; + } + if ( + key === 'advanced' && + !rowFilter && + getUnknownConfigEntries(children, providerValue).length + ) { + return { after: }; + } + return {}; + }; + + return ( + + {!isGlobal && } + + + } + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded={hasEverExpanded} + sticky + > + {!isGlobal && !rowFilter && } +
+ {groups.map((group) => ( + + ))} +
+
+ ); +} diff --git a/src/components/configuration/sections/providers/ProviderGroup.tsx b/src/components/configuration/sections/providers/ProviderGroup.tsx new file mode 100644 index 00000000..eefc929c --- /dev/null +++ b/src/components/configuration/sections/providers/ProviderGroup.tsx @@ -0,0 +1,175 @@ +import { useContext } from 'react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { formatSummary, flattenedObject, summarizeFields, createSummaryReader } from './layout'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { useLocalize, usePersistentExpanded } from '@/hooks'; +import { UnknownSettings } from '../../UnknownSettings'; +import { SettingsSection } from '../../SettingsSection'; +import { isConfigRecord, isRowVisible } from '@/utils'; +import { countConfigured } from '../../counts'; +import { FieldRenderer } from '../../renderer'; +import { SetBadge } from './SetBadge'; + +export const PROVIDERS_STORAGE_KEY = 'providers'; + +/** The group's fields as runs of rows, with declared pairs side by side (recursion limits). */ +function rowsOf(fields: t.SchemaField[], pairs: Array<[string, string]> = []): t.FieldRun[] { + const byKey = new Map(fields.map((field) => [field.key, field])); + const partnerOf = new Map(pairs.filter(([a, b]) => byKey.has(a) && byKey.has(b))); + const consumed = new Set(partnerOf.values()); + const runs: t.FieldRun[] = []; + let run: t.SchemaField[] = []; + for (const field of fields) { + if (consumed.has(field.key)) continue; + const partner = byKey.get(partnerOf.get(field.key) ?? ''); + if (!partner) { + run.push(field); + continue; + } + if (run.length) runs.push({ fields: run, pair: false }); + run = []; + runs.push({ fields: [field, partner], pair: true }); + } + if (run.length) runs.push({ fields: run, pair: false }); + return runs; +} + +/** + * One purpose group of a provider card (AP-4): a collapsible h3 group that + * remembers whether it was open (AP-8). Collapsed, its header carries how + * many settings are set and what they are ("Title model: gpt-5-mini · + * Method: Structured"); expanded, what the group is for. A group made of + * one plain object (Checkpointer) shows that object's rows directly. + */ +export function ProviderGroup({ + group, + providerPath, + providerValue, + stateId, + notice, + summaryOverride, + countBadge, + before, + after, + ...rendererProps +}: t.ProviderGroupSectionProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const values = useContext(ConfigValuesContext); + const { def, fields } = group; + const { configuredPaths, dbOverridePaths, editedValues, showConfiguredOnly, getValue } = + rendererProps; + const paths = fields.map((field) => `${providerPath}.${field.key}`); + const { isExpanded, sectionRef, toggle } = usePersistentExpanded({ + storageKey: PROVIDERS_STORAGE_KEY, + id: stateId, + defaultExpanded: def.expanded, + revealPaths: paths, + }); + + const { configured } = countConfigured(fields, providerPath, configuredPaths); + const overrides = dbOverridePaths + ? countConfigured(fields, providerPath, dbOverridePaths, true).configured + : 0; + const hasContent = fields.length > 0 || before != null || after != null; + if (!hasContent) return null; + if (rowFilter && !paths.some((path) => isRowVisible(rowFilter, path))) return null; + if (showConfiguredOnly && configured === 0 && !rowFilter) return null; + + const flat = flattenedObject(group); + const parentValue = isConfigRecord(providerValue) ? providerValue : {}; + const summary = isExpanded + ? undefined + : (summaryOverride ?? + formatSummary( + summarizeFields( + fields, + providerPath, + createSummaryReader({ + providerPath, + providerValue: parentValue, + getValue, + configuredPaths, + editedValues, + root: values?.configValues ?? null, + }), + localize, + ), + localize, + 2, + )); + + const renderFields = (rowFields: t.SchemaField[], key: string) => ( + + ); + + let body: ReactNode; + if (flat) { + const objectPath = `${providerPath}.${flat.key}`; + const objectValue = getValue(objectPath, parentValue[flat.key]); + body = ( + <> + + {!rowFilter && ( + + )} + + ); + } else { + body = rowsOf(fields, def.pairs).map((run) => + run.pair ? ( +
+ {run.fields.map((field) => ( +
+ {renderFields([field], field.key)} +
+ ))} +
+ ) : ( + renderFields(run.fields, run.fields[0].key) + ), + ); + } + + return ( + + {countBadge} + + {summary && ( + + {summary} + + )} + + } + description={isExpanded && def.descriptionKey ? localize(def.descriptionKey) : undefined} + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded + > + {notice} + {before} + {body} + {after} + + ); +} diff --git a/src/components/configuration/sections/providers/ProviderSection.tsx b/src/components/configuration/sections/providers/ProviderSection.tsx deleted file mode 100644 index 8d30ab37..00000000 --- a/src/components/configuration/sections/providers/ProviderSection.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useContext } from 'react'; -import type * as t from '@/types'; -import { RowFilterContext } from '@/contexts/RowFilterContext'; -import { countConfigured, hasDescendant } from '../../counts'; -import { FieldRenderer, NestedGroup } from '../../renderer'; -import { UnknownSettings } from '../../UnknownSettings'; -import { ConfigSection } from '../../ConfigSection'; -import { InfoBanner } from '../../InfoBanner'; -import { isConfigRecord } from '@/utils'; -import { useLocalize } from '@/hooks'; - -/** Shown at the top of every named-provider section, in this order. */ -const PRIORITY_ORDER = ['disabled', 'apiKey', 'baseURL', 'models', 'name'] as const; - -export function ProviderSection({ - field, - parentPath, - parentValue, - getValue, - onChange, - onResetField, - onDiscardField, - editedValues, - disabled, - permissions, - configuredPaths, - dbOverridePaths, - touchedPaths, - pendingResets, - schemaDefaults, - showConfiguredOnly, - previewMode, - previewScope, - previewChangedPaths, - resolvedValues, - showChangedOnly, - editSessionId, -}: t.ProviderSectionProps) { - const localize = useLocalize(); - const rowFilter = useContext(RowFilterContext); - - const path = `${parentPath}.${field.key}`; - const children = field.children ?? []; - const parentObj = - parentValue && typeof parentValue === 'object' && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - const providerValue = parentObj[field.key] ?? {}; - - const { total, configured } = countConfigured(children, path, configuredPaths); - - const priorityChildren = PRIORITY_ORDER.map((k) => children.find((c) => c.key === k)).filter( - (f): f is t.SchemaField => f != null, - ); - - const hasPrioritySplit = priorityChildren.length > 0; - const priorityKeys = new Set(priorityChildren.map((c) => c.key)); - const restChildren = hasPrioritySplit - ? children.filter((c) => !priorityKeys.has(c.key)) - : children; - - const restConfigured = restChildren.reduce((acc, c) => { - const p = `${path}.${c.key}`; - return acc + (configuredPaths?.has(p) || hasDescendant(p, configuredPaths) ? 1 : 0); - }, 0); - - const label = localize(`com_config_field_${field.key}`); - /** D11: while `endpoints.all` exists, LibreChat reads title settings only from it. */ - const hasGlobalBlock = - field.key !== 'all' && isConfigRecord(getValue(`${parentPath}.all`, parentObj.all)); - - const rendererProps: Omit = { - parentValue: providerValue, - parentPath: path, - getValue, - onChange, - onResetField, - onDiscardField, - editedValues, - disabled, - permissions, - configuredPaths, - dbOverridePaths, - touchedPaths, - pendingResets, - schemaDefaults, - showConfiguredOnly, - previewMode, - previewScope, - previewChangedPaths, - resolvedValues, - showChangedOnly, - editSessionId, - }; - - return ( - - {hasGlobalBlock && ( -
- -
- )} - {hasPrioritySplit ? ( - <> - - {restChildren.length > 0 && ( - `${path}.${child.key}`)} - > - - - )} - - ) : ( - - )} - {!rowFilter && } -
- ); -} diff --git a/src/components/configuration/sections/providers/ProviderStatus.tsx b/src/components/configuration/sections/providers/ProviderStatus.tsx new file mode 100644 index 00000000..9785ccfe --- /dev/null +++ b/src/components/configuration/sections/providers/ProviderStatus.tsx @@ -0,0 +1,80 @@ +import type * as t from '@/types'; +import { useLocalize, useEndpointModels, useEndpointStatus } from '@/hooks'; +import { STATUS_KEYS, providerStatus } from './status'; + +/** + * Active / Not enabled badge of a provider card header (AP-5), from + * `/api/endpoints`. Nothing while the status loads; the explanation and the + * controlling environment variables are in its tooltip and the card's + * status row. + */ +export function ProviderStatusBadge({ provider }: t.ProviderStatusBadgeProps) { + const localize = useLocalize(); + const { data, isLoading, isError } = useEndpointStatus(); + if (isLoading || (!data && !isError)) return null; + const status = providerStatus(provider, data); + const keys = STATUS_KEYS[status.kind]; + const hint = [localize(keys.desc), status.credentialsKey && localize(status.credentialsKey)] + .filter(Boolean) + .join(' '); + return ( + + + ); +} + +/** + * The "Status" row at the top of a provider card (AP-5, "Status & + * credentials"): whether LibreChat serves the provider, where its credentials + * come from, and how many models it offers. Built-in providers take their + * credentials from the server environment, so this is read-only. + */ +export function ProviderStatusRow({ provider }: t.ProviderStatusRowProps) { + const localize = useLocalize(); + const { data, isError } = useEndpointStatus(); + const models = useEndpointModels(); + const status = providerStatus(provider, data); + const keys = STATUS_KEYS[status.kind]; + const modelCount = models.data?.[provider]?.length; + const showsCount = modelCount != null && modelCount > 0 && status.kind !== 'disabled'; + const loading = !data && !isError; + + return ( +
+
+ {localize('com_config_providers_status_label')} +
+
+

+ {!loading && ( + + + + {status.kind !== 'active' && ( + {localize(keys.desc)} + )} + + )} + {status.credentialsKey && ( + {localize(status.credentialsKey)} + )} + {showsCount && ( + + {localize('com_config_providers_status_models', { count: modelCount })} + + )} + {status.kind === 'disabled' && ( + + {localize('com_config_providers_status_endpoints_note')} + + )} +

+
+
+ ); +} diff --git a/src/components/configuration/sections/providers/ProvidersRenderer.tsx b/src/components/configuration/sections/providers/ProvidersRenderer.tsx index b6b81af2..9f222111 100644 --- a/src/components/configuration/sections/providers/ProvidersRenderer.tsx +++ b/src/components/configuration/sections/providers/ProvidersRenderer.tsx @@ -1,92 +1,61 @@ import { useContext } from 'react'; import type * as t from '@/types'; +import { GlobalRevealContext } from '@/contexts/GlobalRevealContext'; +import { CUSTOM_KEY, NETWORK_KEY, providerFields } from './layout'; +import { isPathConfigured, countConfigured } from '../../counts'; import { RowFilterContext } from '@/contexts/RowFilterContext'; -import { ProviderSection } from './ProviderSection'; +import { revealConfigPath } from '../../review/reveal'; +import { NetworkAccessCard } from './NetworkAccess'; import { FieldRenderer } from '../../renderer'; -import { countConfigured } from '../../counts'; +import { ProviderCard } from './ProviderCard'; import { isRowVisible } from '@/utils'; +/** "Overridden by Global" chips on this tab scroll to the Global value. */ +const revealGlobal = (path: string) => revealConfigPath(path); + +/** + * AI providers tab (WS9): Global overrides pinned first (AP-3), one card per + * built-in provider grouped by purpose (AP-4), then the Network access card + * (AP-7). Endpoint-wide settings the panel does not know yet render as plain + * rows below, so the tab stays schema-driven. + */ export function ProvidersRenderer(props: t.FieldRendererProps) { - const { - fields, - parentPath, - parentValue, - getValue, - onChange, - onResetField, - onDiscardField, - editedValues, - disabled, - permissions, - configuredPaths, - dbOverridePaths, - touchedPaths, - pendingResets, - schemaDefaults, - showConfiguredOnly, - previewMode, - previewScope, - previewChangedPaths, - resolvedValues, - showChangedOnly, - editSessionId, - } = props; + const { fields, ...shared } = props; + const { parentPath, showConfiguredOnly, configuredPaths, editedValues } = shared; const rowFilter = useContext(RowFilterContext); - const providerFields = fields.filter( - (f) => - f.key !== 'custom' && - f.children && - f.children.length > 0 && - isRowVisible(rowFilter, `${parentPath}.${f.key}`), - ); - - const otherFields = fields.filter( - (f) => f.key !== 'custom' && (!f.children || f.children.length === 0), + const providers = providerFields(fields).filter((field) => + isRowVisible(rowFilter, `${parentPath}.${field.key}`), ); - const sharedProps: Omit = { - parentPath, - parentValue, - getValue, - onChange, - onResetField, - onDiscardField, - editedValues, - disabled, - permissions, - configuredPaths, - dbOverridePaths, - touchedPaths, - pendingResets, - schemaDefaults, - showConfiguredOnly, - previewMode, - previewScope, - previewChangedPaths, - resolvedValues, - showChangedOnly, - editSessionId, - }; - const visibleProviders = showConfiguredOnly - ? providerFields.filter((f) => { - const { configured } = countConfigured( - f.children ?? [], - `${parentPath}.${f.key}`, - configuredPaths, + ? providers.filter((field) => { + const path = `${parentPath}.${field.key}`; + const edited = Object.keys(editedValues ?? {}).some((p) => p.startsWith(`${path}.`)); + return ( + edited || countConfigured(field.children ?? [], path, configuredPaths).configured > 0 ); - return configured > 0; }) - : providerFields; + : providers; - return ( -
- {visibleProviders.map((field) => ( - - ))} + const network = fields.find((field) => field.key === NETWORK_KEY); + const others = fields.filter( + (field) => + field.key !== CUSTOM_KEY && + field.key !== NETWORK_KEY && + !field.children?.length && + (!showConfiguredOnly || isPathConfigured(`${parentPath}.${field.key}`, configuredPaths)), + ); - {otherFields.length > 0 && } -
+ return ( + +
+ {visibleProviders.map((field) => ( + + ))} + {network && } + {others.length > 0 && } +
+
); } diff --git a/src/components/configuration/sections/providers/SetBadge.tsx b/src/components/configuration/sections/providers/SetBadge.tsx new file mode 100644 index 00000000..198c648c --- /dev/null +++ b/src/components/configuration/sections/providers/SetBadge.tsx @@ -0,0 +1,23 @@ +import type * as t from '@/types'; +import { OverrideCount } from '@/components/shared'; +import { useLocalize } from '@/hooks'; + +/** + * "3 customized" beside a provider or group title (AP-10): how many settings + * have a value from librechat.yaml or the panel, plus the blue "N override" + * count of admin overrides among them. Nothing when none is set. + */ +export function SetBadge({ count, overrides = 0 }: t.SetBadgeProps) { + const localize = useLocalize(); + if (count <= 0) return null; + const label = localize('com_config_providers_customized', { count }); + return ( + <> + + , + {label} + + {overrides > 0 && } + + ); +} diff --git a/src/components/configuration/sections/providers/StaleOverride.tsx b/src/components/configuration/sections/providers/StaleOverride.tsx new file mode 100644 index 00000000..3357807f --- /dev/null +++ b/src/components/configuration/sections/providers/StaleOverride.tsx @@ -0,0 +1,32 @@ +import { Button } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { useLocalize } from '@/hooks'; + +/** + * A stored Azure groups override LibreChat does not apply (D13, HF-9): it has + * no effect today but would take over once LibreChat re-processes overrides, + * so it can be queued for removal. + */ +export function StaleOverride({ pendingRemoval, onRemove }: t.StaleOverrideProps) { + const localize = useLocalize(); + return ( +
+ + {localize( + pendingRemoval ? 'com_config_stale_override_pending' : 'com_config_providers_azure_stale', + )} + + {onRemove && !pendingRemoval && ( +
+ ); +} diff --git a/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx b/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx index ae4348fd..ec792847 100644 --- a/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx +++ b/src/components/configuration/sections/providers/__tests__/ProvidersRenderer.test.tsx @@ -1,99 +1,198 @@ -import { describe, it, expect, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { configSchema } from 'librechat-data-provider'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import type { ReactNode } from 'react'; import type * as t from '@/types'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; import { ProvidersRenderer } from '../ProvidersRenderer'; -import { createField } from '@/test/fixtures'; - -vi.mock('@/hooks/useLocalize', () => ({ - default: () => (key: string) => key, - useLocalize: () => (key: string) => key, -})); - -interface MockTextFieldProps { - id?: string; - value?: string; - onChange?: (value: string) => void; - disabled?: boolean; - 'aria-label'?: string; -} +import { extractSchemaTree } from '@/server/config'; +import { getValueAtPath } from '@/utils'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, vars?: Record) => + vars ? `${key}:${JSON.stringify(vars)}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const endpointFields = tree.find((field) => field.key === 'endpoints')?.children ?? []; -vi.mock('@clickhouse/click-ui', () => ({ - Icon: () => , - MultiAccordion: Object.assign( - ({ children }: { children: React.ReactNode }) =>
{children}
, - { - Item: ({ children }: { children: React.ReactNode }) =>
{children}
, +const CONFIG: t.ConfigRecord = { + endpoints: { + all: { activityModel: 'gpt-5.6-luna' }, + openAI: { titleModel: 'gpt-5-mini', activityModel: 'gpt-x' }, + agents: { recursionLimit: 50, capabilities: ['tools', 'web_search'] }, + azureOpenAI: { + groups: [ + { + group: 'eastus', + apiKey: '${EASTUS_API_KEY}', + instanceName: 'librechat-eastus', + version: '2024-02-15-preview', + models: { 'gpt-4.1': { deploymentName: 'gpt-41' }, 'gpt-4o': true }, + serverless: false, + assistants: true, + }, + { group: 'foundry', apiKey: 'sk-literal', baseURL: 'https://f/models', serverless: true }, + ], }, - ), - TextField: ({ id, value, onChange, disabled, ...rest }: MockTextFieldProps) => ( - onChange?.(e.target.value)} - /> - ), -})); - -const noop = () => {}; -const getValue = (_path: string, fallback: t.ConfigValue) => fallback; -/** Provider sections are collapsible cards that start closed when nothing is configured. */ -const expandOpenAI = () => - fireEvent.click(screen.getByRole('button', { name: /com_config_field_openAI/ })); + }, +}; + +const CONFIGURED = new Set([ + 'endpoints.all.activityModel', + 'endpoints.openAI.titleModel', + 'endpoints.openAI.activityModel', + 'endpoints.agents.recursionLimit', + 'endpoints.agents.capabilities', + 'endpoints.azureOpenAI.groups', +]); + +const STATUS: t.EndpointStatusMap = { + openAI: { name: 'openAI', custom: false, userProvide: false }, + agents: { name: 'agents', custom: false, userProvide: false }, +}; + +function renderProviders(overrides: Partial = {}) { + 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 wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const utils = render( + fallback ?? getValueAtPath(CONFIG, path)} + onChange={onChange} + configuredPaths={CONFIGURED} + editedValues={{}} + {...overrides} + />, + { wrapper }, + ); + return { ...utils, onChange }; +} + +const card = (key: string) => document.getElementById(`section-endpoints.${key}`) as HTMLElement; +const trigger = (key: string) => + card(key).querySelector('.settings-section-trigger') as HTMLButtonElement; + +beforeEach(() => { + localStorage.clear(); +}); describe('ProvidersRenderer', () => { - const providerFields: t.SchemaField[] = [ - createField({ - key: 'openAI', - children: [createField({ key: 'apiKey', type: 'string' })], - }), - ]; - - it('renders a masked secret for a provider apiKey with a display companion', () => { - render( - , - ); - expandOpenAI(); - expect(screen.getByDisplayValue('sk-test...1234')).toBeDisabled(); + it('pins Global overrides first and Network access last, all collapsed', () => { + renderProviders(); + const ids = [...document.querySelectorAll('section[data-settings-section]')].map((s) => s.id); + expect(ids[0]).toBe('section-endpoints.all'); + expect(ids[ids.length - 1]).toBe('section-endpoints.allowedAddresses'); + expect(ids).not.toContain('section-endpoints.custom'); + for (const button of document.querySelectorAll('.settings-section-trigger')) { + expect(button.getAttribute('aria-expanded')).toBe('false'); + } + }); + + it('shows Active / Not enabled from /api/endpoints in provider headers', () => { + renderProviders(); + expect(within(card('openAI')).getByText('com_config_providers_status_active')).toBeTruthy(); + expect(within(card('google')).getByText('com_config_providers_status_disabled')).toBeTruthy(); + expect(within(card('all')).queryByText(/com_config_providers_status_/)).toBeNull(); + }); + + it('summarizes a collapsed card, flagging values Global takes precedence over', () => { + renderProviders(); + const summary = card('openAI').querySelector('.settings-help') as HTMLElement; + expect(summary.textContent).toContain('gpt-5-mini'); + expect(summary.textContent).toContain('com_config_providers_summary_shadowed'); + }); + + it('remembers which provider was open across remounts (AP-8)', () => { + const first = renderProviders(); + fireEvent.click(trigger('openAI')); + expect(trigger('openAI')).toHaveAttribute('aria-expanded', 'true'); + first.unmount(); + renderProviders(); + expect(trigger('openAI')).toHaveAttribute('aria-expanded', 'true'); + expect(trigger('anthropic')).toHaveAttribute('aria-expanded', 'false'); + }); + + it("explains in a provider's title group that Global settings win (D11)", () => { + renderProviders(); + fireEvent.click(trigger('openAI')); + const titles = document.getElementById('section-endpoints.openAI-titles') as HTMLElement; + expect(within(titles).getByText(/com_config_providers_global_titles_banner/)).toBeTruthy(); + expect(within(titles).getByText('com_config_providers_summary_ignored')).toBeTruthy(); }); - it('shows a normal editable input for a provider apiKey while its reset is pending', () => { - render( - , + it('opens Agent features first, with capability descriptions and paired limits', () => { + renderProviders(); + fireEvent.click(trigger('agents')); + const features = document.getElementById('section-endpoints.agents-features') as HTMLElement; + expect(features.querySelector('[aria-expanded="true"]')).toBeTruthy(); + expect(within(features).getByText('Web search')).toBeTruthy(); + expect(within(features).getByText('com_config_providers_cap_web_search_desc')).toBeTruthy(); + expect(features.querySelectorAll('.provider-pair')).toHaveLength(2); + const citations = document.getElementById('section-endpoints.agents-citations') as HTMLElement; + expect(citations.querySelector('[aria-expanded="false"]')).toBeTruthy(); + }); + + it('shows Azure groups read-only, with a drawer that never shows a key', () => { + renderProviders(); + fireEvent.click(trigger('azureOpenAI')); + const azure = card('azureOpenAI'); + expect(within(azure).getByText('com_config_providers_azure_readonly')).toBeTruthy(); + expect(within(azure).getByText('com_config_providers_groups:{"count":2}')).toBeTruthy(); + fireEvent.click( + within(azure).getByRole('button', { + name: 'com_config_providers_azure_details_aria:{"group":"foundry"}', + }), + ); + const drawer = screen.getByRole('dialog'); + expect(within(drawer).getByText('com_config_providers_azure_masked')).toBeTruthy(); + expect(drawer.textContent).not.toContain('sk-literal'); + fireEvent.click( + within(drawer).getByRole('button', { name: 'com_config_providers_azure_close' }), ); - expandOpenAI(); - expect(screen.queryByDisplayValue('sk-test...1234')).not.toBeInTheDocument(); - expect(screen.getByLabelText('apiKey')).not.toBeDisabled(); + fireEvent.click( + within(azure).getByRole('button', { + name: 'com_config_providers_azure_details_aria:{"group":"eastus"}', + }), + ); + const eastus = screen.getByRole('dialog'); + expect(within(eastus).getByText(/EASTUS_API_KEY/)).toBeTruthy(); + expect(within(eastus).getByText('gpt-4.1')).toBeTruthy(); }); - it('forwards editedValues so a cleared-but-queued provider secret stays visible after remount', () => { - render( - , + it('validates network addresses as they are typed and saves valid ones', () => { + const { onChange } = renderProviders(); + fireEvent.click(trigger('allowedAddresses')); + const network = card('allowedAddresses'); + fireEvent.click(within(network).getByRole('button', { name: /com_ui_add_item/ })); + const input = within(network).getByPlaceholderText('com_config_providers_network_placeholder'); + fireEvent.change(input, { target: { value: 'localhost' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(within(network).getByRole('alert').textContent).toBe( + 'com_config_providers_network_issue_port', ); - expandOpenAI(); - expect(screen.queryByDisplayValue('sk-test...1234')).not.toBeInTheDocument(); - expect(screen.getByLabelText('apiKey')).not.toBeDisabled(); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: 'localhost:11434' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith('endpoints.allowedAddresses', ['localhost:11434']); + }); + + it('hides providers with nothing set when only set values are shown', () => { + renderProviders({ showConfiguredOnly: true }); + expect(card('openAI')).toBeTruthy(); + expect(card('google')).toBeNull(); + expect(card('allowedAddresses')).toBeNull(); }); }); diff --git a/src/components/configuration/sections/providers/__tests__/helpers.test.ts b/src/components/configuration/sections/providers/__tests__/helpers.test.ts new file mode 100644 index 00000000..37e60973 --- /dev/null +++ b/src/components/configuration/sections/providers/__tests__/helpers.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { maskCredential, toAzureGroups } from '../azure'; +import { createField } from '@/test/fixtures'; +import { providerTocItems } from '../layout'; +import { providerStatus } from '../status'; +import { addressIssue } from '../network'; + +describe('toAzureGroups', () => { + it('reads groups as LibreChat serves them, blank values as unset', () => { + const [row] = toAzureGroups([ + { + group: 'eastus', + apiKey: '${KEY}', + instanceName: 'eus', + deploymentName: '', + baseURL: '', + serverless: false, + models: { 'gpt-4.1': { deploymentName: 'd', version: 'v' }, 'gpt-4o': true }, + additionalHeaders: { 'api-key': 'secret', n: 3 }, + dropParams: ['stop', 4], + }, + 'junk', + { noGroup: true }, + ]); + expect(row).toMatchObject({ + group: 'eastus', + instanceName: 'eus', + deploymentName: undefined, + baseURL: undefined, + serverless: false, + assistants: false, + additionalHeaders: { 'api-key': 'secret' }, + dropParams: ['stop'], + }); + expect(row.models).toEqual([ + { name: 'gpt-4.1', deploymentName: 'd', version: 'v', assistants: undefined }, + { name: 'gpt-4o' }, + ]); + expect(toAzureGroups(null)).toEqual([]); + }); +}); + +describe('maskCredential', () => { + it('names an environment reference and hides anything else', () => { + expect(maskCredential('${EASTUS_API_KEY}')).toEqual({ kind: 'env', name: 'EASTUS_API_KEY' }); + expect(maskCredential('sk-live-123')).toEqual({ kind: 'masked' }); + expect(maskCredential('prefix-${A}')).toEqual({ kind: 'masked' }); + expect(maskCredential(undefined)).toEqual({ kind: 'none' }); + }); +}); + +describe('addressIssue', () => { + it("reports only the first problem LibreChat's schema finds", () => { + expect(addressIssue('localhost:11434')).toBeNull(); + expect(addressIssue('[::1]:8080')).toBeNull(); + expect(addressIssue('10.0.0.5:80')).toBeNull(); + expect(addressIssue('localhost')).toBe('port'); + expect(addressIssue('http://localhost:11434')).toBe('url'); + expect(addressIssue('8.8.8.8:53')).toBe('public'); + expect(addressIssue('host:99999')).toBe('port'); + expect(addressIssue('')).toBe('empty'); + }); +}); + +describe('providerStatus', () => { + const endpoints = { + openAI: { name: 'openAI', custom: false, userProvide: false }, + google: { name: 'google', custom: false, userProvide: true }, + }; + + it('reads served, user-provided and missing providers', () => { + expect(providerStatus('openAI', endpoints).kind).toBe('active'); + expect(providerStatus('google', endpoints).kind).toBe('userProvided'); + expect(providerStatus('assistants', endpoints)).toEqual({ + kind: 'disabled', + credentialsKey: 'com_config_providers_creds_assistants', + }); + expect(providerStatus('openAI', undefined).kind).toBe('unknown'); + }); +}); + +describe('providerTocItems', () => { + it('lists Global first, the providers in tab order, then Network access', () => { + const fields = ['allowedAddresses', 'agents', 'all', 'openAI', 'custom'].map((key) => + createField({ + key, + type: key === 'allowedAddresses' ? 'array' : 'object', + children: key === 'allowedAddresses' ? undefined : [createField({ key: 'x' })], + }), + ); + const items = providerTocItems(fields, 'endpoints', (key) => key); + expect(items.map((item) => item.id)).toEqual([ + 'section-endpoints.all', + 'section-endpoints.openAI', + 'section-endpoints.agents', + 'section-endpoints.allowedAddresses', + ]); + expect(items[3].label).toBe('com_config_providers_network_title'); + }); +}); diff --git a/src/components/configuration/sections/providers/__tests__/layout.test.ts b/src/components/configuration/sections/providers/__tests__/layout.test.ts new file mode 100644 index 00000000..bacce198 --- /dev/null +++ b/src/components/configuration/sections/providers/__tests__/layout.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { + summaryValue, + formatSummary, + providerLayout, + summarizeFields, + flattenedObject, + resolveProviderGroups, +} from '../layout'; +import { PROVIDER_FIELD_META } from '../../../meta/providers'; +import translation from '@/locales/en/translation.json'; +import { extractSchemaTree } from '@/server/config'; +import { createField } from '@/test/fixtures'; + +const localeKeys = new Set(Object.keys(translation)); +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const endpoints = tree.find((field) => field.key === 'endpoints'); +const providers = (endpoints?.children ?? []).filter( + (field) => field.key !== 'custom' && (field.children?.length ?? 0) > 0, +); + +const localize: t.LocalizeFn = (key, values) => + values ? `${key}(${Object.values(values).join('|')})` : key; + +describe('provider layouts (providerMeta coverage)', () => { + it('covers the providers of the schema', () => { + expect(providers.map((field) => field.key).sort()).toEqual( + [ + 'agents', + 'all', + 'anthropic', + 'assistants', + 'azureAssistants', + 'azureOpenAI', + 'bedrock', + 'google', + 'openAI', + ].sort(), + ); + }); + + it.each(providers.map((field) => [field.key, field] as const))( + '%s: every schema key is placed in exactly one group or in Advanced', + (key, field) => { + const children = field.children ?? []; + const { groups } = resolveProviderGroups(key, children); + const placed = groups.flatMap((group) => group.fields.map((child) => child.key)); + expect(placed.sort()).toEqual(children.map((child) => child.key).sort()); + }, + ); + + it.each(providers.map((field) => [field.key, field] as const))( + '%s: no schema key falls back to Advanced unplanned', + (key, field) => { + expect(resolveProviderGroups(key, field.children ?? []).fallback).toEqual([]); + }, + ); + + it('names no key that no provider has (no dead keys, unlike the old PRIORITY_ORDER)', () => { + const known = new Set(providers.flatMap((field) => (field.children ?? []).map((c) => c.key))); + const dead = providers + .flatMap((field) => providerLayout(field.key)) + .flatMap((group) => group.fields) + .filter((key) => !known.has(key)); + expect(dead).toEqual([]); + }); + + it('puts unknown provider keys into Advanced, before the unused group', () => { + const fields = [ + createField({ key: 'baseURL', type: 'string' }), + createField({ key: 'titleModel', type: 'string' }), + createField({ key: 'brandNew', type: 'string' }), + ]; + const { groups, fallback } = resolveProviderGroups('openAI', fields); + expect(fallback).toEqual(['brandNew']); + expect(groups.map((group) => group.def.key)).toEqual(['titles', 'advanced', 'unused']); + expect(groups[1].fields.map((field) => field.key)).toEqual(['brandNew']); + }); + + it('always returns Advanced, empty when nothing falls into it', () => { + const { groups } = resolveProviderGroups('openAI', [ + createField({ key: 'titleModel', type: 'string' }), + ]); + expect(groups.map((group) => [group.def.key, group.fields.length])).toEqual([ + ['titles', 1], + ['advanced', 0], + ]); + }); + + it('opens Agent features first, with the recursion limits side by side', () => { + const [features] = providerLayout('agents'); + expect(features.key).toBe('features'); + expect(features.expanded).toBe(true); + expect(features.fields).toContain('capabilities'); + expect(features.pairs).toContainEqual(['recursionLimit', 'maxRecursionLimit']); + expect(providerLayout('azureOpenAI')[0].fields[0]).toBe('groups'); + }); + + it('every locale key of the layouts and the provider metadata has a translation', () => { + const keys = new Set(); + for (const groups of providers.map((field) => providerLayout(field.key))) { + for (const group of groups) { + keys.add(group.labelKey); + if (group.descriptionKey) keys.add(group.descriptionKey); + } + } + for (const meta of Object.values(PROVIDER_FIELD_META)) { + if (meta.labelKey) keys.add(meta.labelKey); + if (meta.descriptionKey) keys.add(meta.descriptionKey); + for (const key of Object.values(meta.enumLabels ?? {})) keys.add(key); + for (const key of Object.values(meta.enumDescriptions ?? {})) keys.add(key); + } + expect([...keys].filter((key) => !localeKeys.has(key))).toEqual([]); + }); + + it('labels every agent capability the schema allows', () => { + const agents = providers.find((field) => field.key === 'agents'); + const capabilities = agents?.children?.find((field) => field.key === 'capabilities'); + const members = /enum\((.+)\)/.exec(capabilities?.type ?? '')?.[1].split(' | ') ?? []; + const labels = PROVIDER_FIELD_META['endpoints.agents.capabilities'].enumLabels ?? {}; + expect(members.length).toBeGreaterThan(0); + expect(members.filter((member) => !labels[member])).toEqual([]); + }); +}); + +describe('flattenedObject', () => { + it('flattens a group made of one plain object', () => { + const object = createField({ + key: 'checkpointer', + type: 'object', + isObject: true, + children: [createField({ key: 'ttl', type: 'number' })], + }); + expect(flattenedObject({ def: providerLayout('agents')[0], fields: [object] })).toBe(object); + }); + + it('keeps lists, records and several fields as they are', () => { + const def = providerLayout('agents')[0]; + const list = createField({ + key: 'environments', + type: 'array', + isArray: true, + children: [createField({ key: 'id', type: 'string' })], + }); + expect(flattenedObject({ def, fields: [list] })).toBeUndefined(); + expect( + flattenedObject({ + def, + fields: [createField({ key: 'a' }), createField({ key: 'b' })], + }), + ).toBeUndefined(); + }); +}); + +describe('summaries', () => { + const titleModel = createField({ key: 'titleModel', type: 'string', path: 'x.titleModel' }); + const models = createField({ key: 'models', type: 'array', isArray: true }); + + it('summarizes only the set fields, with list previews', () => { + const values: t.ConfigRecord = { + 'p.titleModel': 'gpt-5-mini', + 'p.models': ['a', 'b', 'c'], + }; + const items = summarizeFields( + [titleModel, models, createField({ key: 'unset' })], + 'p', + { + isSet: (path) => path in values, + valueAt: (path) => values[path], + isShadowed: (path) => path === 'p.titleModel', + }, + localize, + ); + expect(items.map((item) => item.value)).toEqual([ + 'com_config_providers_summary_shadowed(gpt-5-mini)', + 'com_config_providers_summary_more(a, b|1)', + ]); + }); + + it('counts enum selections and Azure groups', () => { + const capabilities = createField({ key: 'capabilities', type: 'array' }); + expect(summaryValue(capabilities, ['a', 'b'], localize)).toBe( + 'com_config_providers_summary_selected(2)', + ); + const groups = createField({ key: 'groups', type: 'array' }); + expect(summaryValue(groups, [{ group: 'x' }], localize)).toBe('com_config_providers_groups(1)'); + }); + + it('formats records and empty lists', () => { + expect(summaryValue(models, [], localize)).toBe('com_config_providers_summary_none'); + expect(summaryValue(createField({ key: 'h', type: 'record' }), { a: '1' }, localize)).toBe( + 'com_config_providers_summary_keys(1)', + ); + }); + + it('joins at most three items and counts the rest', () => { + const items = ['a', 'b', 'c', 'd'].map((label) => ({ label, value: 'v' })); + expect(formatSummary(items, localize)).toBe( + 'com_config_providers_summary_pair(a|v) · com_config_providers_summary_pair(b|v) · com_config_providers_summary_pair(c|v) · com_config_providers_summary_rest(1)', + ); + expect(formatSummary([], localize)).toBeUndefined(); + }); +}); diff --git a/src/components/configuration/sections/providers/__tests__/secrets.test.tsx b/src/components/configuration/sections/providers/__tests__/secrets.test.tsx new file mode 100644 index 00000000..5be556df --- /dev/null +++ b/src/components/configuration/sections/providers/__tests__/secrets.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type * as t from '@/types'; +import { ProvidersRenderer } from '../ProvidersRenderer'; +import { createField } from '@/test/fixtures'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +interface MockTextFieldProps { + id?: string; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + 'aria-label'?: string; +} + +vi.mock('@clickhouse/click-ui', () => ({ + Icon: () => , + MultiAccordion: Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { + Item: ({ children }: { children: React.ReactNode }) =>
{children}
, + }, + ), + TextField: ({ id, value, onChange, disabled, ...rest }: MockTextFieldProps) => ( + onChange?.(e.target.value)} + /> + ), +})); + +const noop = () => {}; +const getValue = (_path: string, fallback: t.ConfigValue) => fallback; +/** Provider sections are collapsible cards that start closed when nothing is configured. */ +const expandOpenAI = () => fireEvent.click(screen.getByRole('button', { name: /^openAI/ })); + +describe('ProvidersRenderer', () => { + const providerFields: t.SchemaField[] = [ + createField({ + key: 'openAI', + children: [createField({ key: 'apiKey', type: 'string' })], + }), + ]; + + it('renders a masked secret for a provider apiKey with a display companion', () => { + render( + , + ); + expandOpenAI(); + expect(screen.getByDisplayValue('sk-test...1234')).toBeDisabled(); + }); + + it('shows a normal editable input for a provider apiKey while its reset is pending', () => { + render( + , + ); + expandOpenAI(); + expect(screen.queryByDisplayValue('sk-test...1234')).not.toBeInTheDocument(); + expect(screen.getByLabelText('apiKey')).not.toBeDisabled(); + }); + + it('forwards editedValues so a cleared-but-queued provider secret stays visible after remount', () => { + render( + , + ); + expandOpenAI(); + expect(screen.queryByDisplayValue('sk-test...1234')).not.toBeInTheDocument(); + expect(screen.getByLabelText('apiKey')).not.toBeDisabled(); + }); +}); diff --git a/src/components/configuration/sections/providers/azure.ts b/src/components/configuration/sections/providers/azure.ts new file mode 100644 index 00000000..1799d2d6 --- /dev/null +++ b/src/components/configuration/sections/providers/azure.ts @@ -0,0 +1,71 @@ +import type * as t from '@/types'; +import { isConfigRecord, isEnvReference } from '@/utils'; + +function text(value: t.ConfigValue): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +function stringRecord(value: t.ConfigValue): Record | undefined { + if (!isConfigRecord(value)) return undefined; + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** A group's models: `name: true` (deployed under its own name) or per-model overrides. */ +function toModels(value: t.ConfigValue): t.AzureGroupModel[] { + if (!isConfigRecord(value)) return []; + return Object.entries(value).map(([name, config]) => { + if (!isConfigRecord(config)) return { name }; + return { + name, + deploymentName: text(config.deploymentName), + version: text(config.version), + assistants: config.assistants === true ? true : undefined, + }; + }); +} + +/** + * The Azure OpenAI groups as the read-only table shows them (AP-1, D13), + * from the normalized `endpoints.azureOpenAI.groups`. LibreChat fills unset + * values with `''` and `false`; blank text reads as unset. + */ +export function toAzureGroups(value: t.ConfigValue): t.AzureGroupRow[] { + if (!Array.isArray(value)) return []; + const rows: t.AzureGroupRow[] = []; + for (const entry of value) { + if (!isConfigRecord(entry) || typeof entry.group !== 'string') continue; + rows.push({ + group: entry.group, + apiKey: text(entry.apiKey), + instanceName: text(entry.instanceName), + baseURL: text(entry.baseURL), + deploymentName: text(entry.deploymentName), + version: text(entry.version), + serverless: entry.serverless === true, + assistants: entry.assistants === true, + models: toModels(entry.models), + additionalHeaders: stringRecord(entry.additionalHeaders), + addParams: isConfigRecord(entry.addParams) ? entry.addParams : undefined, + dropParams: Array.isArray(entry.dropParams) + ? entry.dropParams.filter((param): param is string => typeof param === 'string') + : undefined, + }); + } + return rows; +} + +const ENV_NAME = /^\$\{([A-Za-z0-9_]+)\}$/; + +/** + * How a credential is shown: an environment reference (`${EASTUS_API_KEY}`) + * by its variable name, any literal value hidden. + */ +export function maskCredential(value: string | undefined): t.MaskedCredential { + if (!value) return { kind: 'none' }; + const match = isEnvReference(value) ? ENV_NAME.exec(value) : null; + if (match) return { kind: 'env', name: match[1] }; + return { kind: 'masked' }; +} diff --git a/src/components/configuration/sections/providers/index.ts b/src/components/configuration/sections/providers/index.ts index 98a878a9..c2c6e047 100644 --- a/src/components/configuration/sections/providers/index.ts +++ b/src/components/configuration/sections/providers/index.ts @@ -1,6 +1,7 @@ /** - * AI Providers tab: renders named providers (openAI, anthropic, …) of the - * `endpoints` section as collapsible sections where the most-important fields - * are always shown first. New schema fields surface automatically. + * AI providers tab (WS9): Global overrides, one purpose-grouped card per + * built-in provider, and Network access. New schema fields surface + * automatically in each provider's Advanced group. */ export { ProvidersRenderer } from './ProvidersRenderer'; +export { providerTocItems } from './layout'; diff --git a/src/components/configuration/sections/providers/layout.ts b/src/components/configuration/sections/providers/layout.ts new file mode 100644 index 00000000..d0397bd9 --- /dev/null +++ b/src/components/configuration/sections/providers/layout.ts @@ -0,0 +1,234 @@ +import type * as t from '@/types'; +import { DEFAULT_PROVIDER_LAYOUT, PROVIDER_LAYOUTS, labelForPath } from '../../meta'; +import { getValueAtPath, isConfigRecord, resolveShadowedBy } from '@/utils'; +import { BUILT_IN_PROVIDERS, GLOBAL_PROVIDER_KEY } from '@/constants'; +import { formatFieldValue, getFieldOptions } from '../../controls'; + +const ADVANCED_KEY = 'advanced'; +export const NETWORK_KEY = 'allowedAddresses'; +export const CUSTOM_KEY = 'custom'; +const GROUPS_KEY = 'groups'; +const ENUM_LIST = /^array [field.key, field])); + const placed = new Set(); + const groups: t.ProviderGroup[] = []; + for (const def of layout) { + const groupFields: t.SchemaField[] = []; + for (const key of def.fields) { + const field = byKey.get(key); + if (!field || placed.has(key)) continue; + placed.add(key); + groupFields.push(field); + } + groups.push({ def, fields: groupFields }); + } + const fallback = fields.filter((field) => !placed.has(field.key)); + let advanced = groups.find((group) => group.def.key === ADVANCED_KEY); + if (!advanced) { + const def = DEFAULT_PROVIDER_LAYOUT.find((group) => group.key === ADVANCED_KEY); + advanced = { def: { ...(def as t.ProviderGroupDef), fields: [] }, fields: [] }; + const unusedIndex = groups.findIndex((group) => group.def.unused); + groups.splice(unusedIndex === -1 ? groups.length : unusedIndex, 0, advanced); + } + advanced.fields.push(...fallback); + return { + groups: groups.filter((group) => group.fields.length > 0 || group === advanced), + fallback: fallback.map((field) => field.key), + }; +} + +/** A group made of one plain object (Checkpointer, Guardrails): its rows render directly in the group. */ +export function flattenedObject(group: t.ProviderGroup): t.SchemaField | undefined { + if (group.fields.length !== 1) return undefined; + const [field] = group.fields; + const isPlainObject = + !!field.children?.length && + !field.isArray && + field.type !== 'record' && + !field.variants?.length && + !field.type.startsWith('union('); + return isPlainObject ? field : undefined; +} + +function listPreview(values: t.ConfigValue[], localize: t.LocalizeFn): string { + const names = values.map((item) => { + if (typeof item === 'string') return item; + if (isConfigRecord(item) && typeof item.name === 'string') return item.name; + return localize('com_config_providers_summary_item'); + }); + const shown = names.slice(0, MAX_LIST_PREVIEW).join(', '); + const rest = names.length - MAX_LIST_PREVIEW; + return rest > 0 ? localize('com_config_providers_summary_more', { shown, count: rest }) : shown; +} + +/** Short text for a set value: option labels, On/Off, units, a list preview or a key count. */ +export function summaryValue( + field: t.SchemaField, + value: t.ConfigValue, + localize: t.LocalizeFn, +): string { + if (Array.isArray(value)) { + if (value.length === 0) return localize('com_config_providers_summary_none'); + if (ENUM_LIST.test(field.type)) { + return localize('com_config_providers_summary_selected', { count: value.length }); + } + if (field.key === GROUPS_KEY) + return localize('com_config_providers_groups', { count: value.length }); + if (value.every((item) => typeof item === 'string')) { + const labels = new Map(getFieldOptions(field, localize).map((o) => [o.value, o.label])); + return listPreview( + value.map((item) => labels.get(String(item)) ?? item), + localize, + ); + } + return listPreview(value, localize); + } + if (isConfigRecord(value)) { + return localize('com_config_providers_summary_keys', { count: Object.keys(value).length }); + } + return formatFieldValue(field, value, localize) ?? ''; +} + +/** + * The set values among `fields` (librechat.yaml, an override or a pending + * edit), as "Label: value" items for a card or group summary line. + */ +export function summarizeFields( + fields: t.SchemaField[], + parentPath: string, + read: t.SummaryReader, + localize: t.LocalizeFn, +): t.SummaryItem[] { + const items: t.SummaryItem[] = []; + for (const field of fields) { + const path = `${parentPath}.${field.key}`; + if (!read.isSet(path)) continue; + const value = read.valueAt(path); + if (value === undefined) continue; + const text = summaryValue(field, value, localize); + items.push({ + label: labelForPath(path, localize), + value: read.isShadowed?.(path) + ? localize('com_config_providers_summary_shadowed', { value: text }) + : text, + }); + } + return items; +} + +/** "Title model: gpt-5-mini · Activity labels: On · +3 more". */ +export function formatSummary( + items: t.SummaryItem[], + localize: t.LocalizeFn, + limit = 3, +): string | undefined { + if (items.length === 0) return undefined; + const shown = items + .slice(0, limit) + .map(({ label, value }) => localize('com_config_providers_summary_pair', { label, value })) + .join(' · '); + const rest = items.length - limit; + return rest > 0 + ? `${shown} · ${localize('com_config_providers_summary_rest', { count: rest })}` + : shown; +} + +/** Whether a path holds a value: librechat.yaml, an override, or a pending edit at or under it. */ +export function isPathSet( + path: string, + configuredPaths: ReadonlySet | undefined, + editedValues: t.FlatConfigMap | undefined, +): boolean { + const prefix = `${path}.`; + const matches = (candidate: string) => candidate === path || candidate.startsWith(prefix); + if (configuredPaths) { + for (const candidate of configuredPaths) if (matches(candidate)) return true; + } + for (const [candidate, value] of Object.entries(editedValues ?? {})) { + if (value !== undefined && matches(candidate)) return true; + } + return false; +} + +/** + * How a provider's summaries read its values: set paths (saved or pending), + * the shown value, and whether Global overrides win over it (`root` is the + * shown configuration, pending edits included). + */ +export function createSummaryReader({ + providerPath, + providerValue, + getValue, + configuredPaths, + editedValues, + root, +}: t.SummaryReaderInput): t.SummaryReader { + return { + isSet: (path) => isPathSet(path, configuredPaths, editedValues), + valueAt: (path) => + getValue(path, getValueAtPath(providerValue, path.slice(providerPath.length + 1))), + isShadowed: (path) => resolveShadowedBy(path, root, editedValues) != null, + }; +} + +/** Global overrides first, then the built-in providers in their usual order, then any new ones. */ +export function providerRank(key: string): number { + if (key === GLOBAL_PROVIDER_KEY) return -1; + const index = BUILT_IN_PROVIDERS.indexOf(key); + return index === -1 ? BUILT_IN_PROVIDERS.length : index; +} + +/** The provider fields of the `endpoints` section (objects other than `custom`), in tab order. */ +export function providerFields(fields: t.SchemaField[]): t.SchemaField[] { + return fields + .filter((field) => field.key !== CUSTOM_KEY && (field.children?.length ?? 0) > 0) + .sort((a, b) => providerRank(a.key) - providerRank(b.key)); +} + +/** + * Table of contents of the AI providers tab (WS5 shell, WS9 entries): Global + * overrides, each provider, and Network access when the schema has it. + */ +export function providerTocItems( + fields: t.SchemaField[], + dataKey: string, + localize: t.LocalizeFn, +): t.TocItem[] { + const items: t.TocItem[] = providerFields(fields).map((field) => { + const path = `${dataKey}.${field.key}`; + const label = + field.key === GLOBAL_PROVIDER_KEY + ? localize('com_config_providers_global_short') + : labelForPath(path, localize); + return { id: `section-${path}`, label, dataPath: path, path }; + }); + if (fields.some((field) => field.key === NETWORK_KEY)) { + const path = `${dataKey}.${NETWORK_KEY}`; + items.push({ + id: `section-${path}`, + label: localize('com_config_providers_network_title'), + dataPath: path, + path, + }); + } + return items; +} diff --git a/src/components/configuration/sections/providers/network.ts b/src/components/configuration/sections/providers/network.ts new file mode 100644 index 00000000..cc875efa --- /dev/null +++ b/src/components/configuration/sections/providers/network.ts @@ -0,0 +1,31 @@ +import { allowedAddressesSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; + +/** LibreChat's `allowedAddresses` refinements, in the order it checks them. */ +const ISSUE_PATTERNS: ReadonlyArray<[RegExp, t.AddressIssue]> = [ + [/non-empty/, 'empty'], + [/host:port pairs/, 'url'], + [/include a port/, 'port'], + [/private IP space/, 'public'], +]; + +export const ADDRESS_ISSUE_KEYS: Readonly> = { + empty: 'com_config_providers_network_issue_empty', + url: 'com_config_providers_network_issue_url', + port: 'com_config_providers_network_issue_port', + public: 'com_config_providers_network_issue_public', + format: 'com_config_providers_network_issue_format', +}; + +/** + * The first problem LibreChat would report for an `allowedAddresses` entry + * (AP-7), checked with its own schema: Zod runs every refinement, so + * `localhost` fails both "include a port" and "private IP space", and only + * the first is relevant. Null when the entry is valid. + */ +export function addressIssue(entry: string): t.AddressIssue | null { + const result = allowedAddressesSchema.safeParse([entry]); + if (result.success) return null; + const message = result.error.issues[0]?.message ?? ''; + return ISSUE_PATTERNS.find(([pattern]) => pattern.test(message))?.[1] ?? 'format'; +} diff --git a/src/components/configuration/sections/providers/status.ts b/src/components/configuration/sections/providers/status.ts new file mode 100644 index 00000000..919b289d --- /dev/null +++ b/src/components/configuration/sections/providers/status.ts @@ -0,0 +1,39 @@ +import type * as t from '@/types'; +import { PROVIDER_CREDENTIALS_KEYS } from '@/constants'; + +/** + * A provider's runtime status from `/api/endpoints` (AP-5): served (with the + * server's credentials, or each user's), not enabled (left out of the list), + * or unknown while the list is missing. + */ +export function providerStatus( + provider: string, + endpoints: t.EndpointStatusMap | undefined, +): t.ProviderStatusInfo { + const credentialsKey = PROVIDER_CREDENTIALS_KEYS[provider]; + if (!endpoints) return { kind: 'unknown', credentialsKey }; + const entry = endpoints[provider]; + if (!entry) return { kind: 'disabled', credentialsKey }; + return { kind: entry.userProvide ? 'userProvided' : 'active', credentialsKey }; +} + +/** Locale keys of a status kind's label and explanation. */ +export const STATUS_KEYS: Readonly> = + { + active: { + label: 'com_config_providers_status_active', + desc: 'com_config_providers_status_active_desc', + }, + userProvided: { + label: 'com_config_providers_status_userProvided', + desc: 'com_config_providers_status_userProvided_desc', + }, + disabled: { + label: 'com_config_providers_status_disabled', + desc: 'com_config_providers_status_disabled_desc', + }, + unknown: { + label: 'com_config_providers_status_unknown', + desc: 'com_config_providers_status_unknown_desc', + }, + }; diff --git a/src/components/configuration/values/ProvenanceChip.tsx b/src/components/configuration/values/ProvenanceChip.tsx index 886284c2..8bedfe25 100644 --- a/src/components/configuration/values/ProvenanceChip.tsx +++ b/src/components/configuration/values/ProvenanceChip.tsx @@ -1,7 +1,10 @@ +import { useContext } from 'react'; import { HoverCard } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { describeStack, sourceLabel, formatLayerValue, formatConfigValue } from './labels'; import { useLocalize, useFieldState, useValuesScope, useHasValueLayers } from '@/hooks'; +import { GlobalRevealContext } from '@/contexts/GlobalRevealContext'; +import { DecorativeIcon } from '@/components/shared'; import { labelForPath } from '../meta'; import { cn } from '@/utils'; @@ -36,21 +39,43 @@ export function showsShadow( return state.shadowedBy != null && (state.isConfigured || state.isOverride || state.isEdited); } -/** "Ignored (Global)": a value `endpoints.all` takes precedence over (D11), with the reason on hover. */ -export function ShadowBadge({ shadowedBy }: { shadowedBy: t.ShadowedBy }) { +/** + * "Overridden by Global: gpt-5.6-luna" (D11): a value Global overrides + * (`endpoints.all`) take precedence over. The row's own provenance + * (`source`, e.g. librechat.yaml) is folded into the reason on hover, so the + * row carries one chip. On the AI providers tab it links to the Global value. + */ +export function ShadowBadge({ shadowedBy, source }: t.ShadowBadgeProps) { const localize = useLocalize(); + const reveal = useContext(GlobalRevealContext); const field = labelForPath(shadowedBy.path, localize); const value = formatConfigValue(shadowedBy.value, localize, shadowedBy.path); + const hasValue = shadowedBy.value !== undefined; const hint = localize( shadowedBy.precedence === 'block' - ? 'com_config_values_shadowed_block' - : 'com_config_values_shadowed_field', - { field, value }, + ? 'com_config_providers_shadow_block' + : 'com_config_providers_shadow_field', + { field, value, source: source ?? localize('com_config_ui_state_edited') }, ); return ( - {localize('com_config_values_shadowed')} + + {hasValue + ? localize('com_config_providers_shadow_label_value', { value }) + : localize('com_config_providers_shadow_label')} + {`, ${hint}`} + {reveal && ( + + )} ); } @@ -65,7 +90,6 @@ export function ShadowBadge({ shadowedBy }: { shadowedBy: t.ShadowedBy }) { */ export function ProvenanceChip({ state, scope, hideDefault, path = '' }: t.ProvenanceChipProps) { const localize = useLocalize(); - const shadow = showsShadow(state) ? : null; if (state.ignoredOverride) { return ( ); } - if (hideDefault && isDefaultValue(state) && !state.isHidden) return shadow; - const text = state.isHidden ? localize('com_config_values_source_hidden') : sourceLabel(state.source, localize, scope); + if (showsShadow(state)) return ; + if (hideDefault && isDefaultValue(state) && !state.isHidden) return null; + const chip = state.isHidden ? 'hidden' : SOURCE_CHIPS[state.source]; const stack = state.stack ?? []; const summary = describeStack(state, localize, path, scope); @@ -130,7 +155,6 @@ export function ProvenanceChip({ state, scope, hideDefault, path = '' }: t.Prove )} - {shadow} ); } diff --git a/src/components/configuration/values/values.test.tsx b/src/components/configuration/values/values.test.tsx index 3756dc6d..9647d668 100644 --- a/src/components/configuration/values/values.test.tsx +++ b/src/components/configuration/values/values.test.tsx @@ -91,8 +91,10 @@ describe('ProvenanceChip', () => { 'endpoints.anthropic.activityModel', valuesDataFixture(baseLayersFixture()), ); - render(); - expect(screen.getByText('com_config_values_shadowed')).toBeTruthy(); + const { container } = render(); + expect(screen.getByText(/com_config_providers_shadow_label/)).toBeTruthy(); + expect(container.querySelectorAll('.state-chip')).toHaveLength(1); + expect(container.querySelector('[data-provenance]')).toBeNull(); }); it('renders nothing for a plain default when asked to', () => { diff --git a/src/constants/index.ts b/src/constants/index.ts index d4e39632..4f642a75 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -4,3 +4,4 @@ export * from './role'; export * from './scope'; export * from './config'; export * from './collections'; +export * from './providers'; diff --git a/src/constants/providers.ts b/src/constants/providers.ts new file mode 100644 index 00000000..42f9d7b9 --- /dev/null +++ b/src/constants/providers.ts @@ -0,0 +1,32 @@ +import { EModelEndpoint } from 'librechat-data-provider'; + +/** Built-in providers, in the order the AI providers tab lists them. */ +export const BUILT_IN_PROVIDERS: readonly string[] = [ + EModelEndpoint.openAI, + EModelEndpoint.google, + EModelEndpoint.anthropic, + EModelEndpoint.azureOpenAI, + EModelEndpoint.azureAssistants, + EModelEndpoint.assistants, + EModelEndpoint.agents, + EModelEndpoint.bedrock, +]; + +/** The key of the block whose settings apply to every endpoint (D11). */ +export const GLOBAL_PROVIDER_KEY = 'all'; + +/** + * Where each built-in provider's credentials come from (LibreChat's + * `EndpointService`): a locale key naming the environment variables, shown in + * the provider's status row and badge tooltip. + */ +export const PROVIDER_CREDENTIALS_KEYS: Readonly> = { + [EModelEndpoint.openAI]: 'com_config_providers_creds_openAI', + [EModelEndpoint.google]: 'com_config_providers_creds_google', + [EModelEndpoint.anthropic]: 'com_config_providers_creds_anthropic', + [EModelEndpoint.azureOpenAI]: 'com_config_providers_creds_azureOpenAI', + [EModelEndpoint.azureAssistants]: 'com_config_providers_creds_azureAssistants', + [EModelEndpoint.assistants]: 'com_config_providers_creds_assistants', + [EModelEndpoint.agents]: 'com_config_providers_creds_agents', + [EModelEndpoint.bedrock]: 'com_config_providers_creds_bedrock', +}; diff --git a/src/contexts/GlobalRevealContext.ts b/src/contexts/GlobalRevealContext.ts new file mode 100644 index 00000000..655a1fa5 --- /dev/null +++ b/src/contexts/GlobalRevealContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +/** + * Scrolls to a Global overrides (`endpoints.all`) setting on the same page. + * Provided by the AI providers tab, so an "Overridden by Global" chip there + * links to the value that wins (D11); elsewhere the chip has no link. + */ +export const GlobalRevealContext = createContext<((path: string) => void) | null>(null); diff --git a/src/hooks/config/accordion.test.tsx b/src/hooks/config/accordion.test.tsx new file mode 100644 index 00000000..66312e79 --- /dev/null +++ b/src/hooks/config/accordion.test.tsx @@ -0,0 +1,75 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { readStoredExpanded, usePersistentExpanded } from './accordion'; +import { ExpandProvider } from '@/contexts/ExpandContext'; +import { useExpandRequest } from './expand'; + +const KEY = 'librechat-admin:open:providers'; + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('usePersistentExpanded', () => { + it('starts collapsed, and remembers that it was opened', () => { + const first = renderHook(() => + usePersistentExpanded({ storageKey: 'providers', id: 'openAI' }), + ); + expect(first.result.current.isExpanded).toBe(false); + act(() => first.result.current.toggle()); + expect(JSON.parse(localStorage.getItem(KEY) ?? '[]')).toEqual(['openAI']); + first.unmount(); + const second = renderHook(() => + usePersistentExpanded({ storageKey: 'providers', id: 'openAI' }), + ); + expect(second.result.current.isExpanded).toBe(true); + }); + + it('remembers that a group open by default was closed', () => { + const options = { storageKey: 'providers', id: 'agents.features', defaultExpanded: true }; + const first = renderHook(() => usePersistentExpanded(options)); + expect(first.result.current.isExpanded).toBe(true); + act(() => first.result.current.toggle()); + first.unmount(); + expect(readStoredExpanded('providers', 'agents.features', true)).toBe(false); + expect(readStoredExpanded('providers', 'agents.other', true)).toBe(true); + }); + + it('follows Expand all and Collapse all', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook( + () => ({ + state: usePersistentExpanded({ storageKey: 'providers', id: 'google' }), + request: useExpandRequest(), + }), + { wrapper }, + ); + act(() => result.current.request('expand')); + expect(result.current.state.isExpanded).toBe(true); + act(() => result.current.request('collapse')); + expect(result.current.state.isExpanded).toBe(false); + }); + + it('works without storage (private mode)', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + const { result } = renderHook(() => usePersistentExpanded({ storageKey: 'p', id: 'x' })); + act(() => result.current.toggle()); + expect(result.current.isExpanded).toBe(true); + }); + + it('ignores junk in storage', () => { + localStorage.setItem(KEY, '{"not":"a list"}'); + expect(readStoredExpanded('providers', 'openAI', false)).toBe(false); + localStorage.setItem(KEY, 'not json'); + expect(readStoredExpanded('providers', 'openAI', true)).toBe(true); + }); +}); diff --git a/src/hooks/config/accordion.ts b/src/hooks/config/accordion.ts new file mode 100644 index 00000000..58d90cd9 --- /dev/null +++ b/src/hooks/config/accordion.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type * as t from '@/types'; +import { useExpandableState, useRevealingFilter } from './expand'; + +const STORAGE_PREFIX = 'librechat-admin:open:'; + +/** The ids stored as open under a key; empty when storage is unavailable or holds junk. */ +function readOpenIds(storageKey: string): Set { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + storageKey); + const parsed: t.ConfigValue = raw ? JSON.parse(raw) : []; + return new Set( + Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [], + ); + } catch { + return new Set(); + } +} + +function writeOpen(storageKey: string, id: string, open: boolean): void { + try { + const ids = readOpenIds(storageKey); + if (open === ids.has(id)) return; + if (open) ids.add(id); + else ids.delete(id); + localStorage.setItem(STORAGE_PREFIX + storageKey, JSON.stringify([...ids])); + } catch { + return; + } +} + +/** Ids closed by the admin that open by default (kept apart, so other defaults still apply). */ +function readClosedIds(storageKey: string): Set { + return readOpenIds(`${storageKey}:closed`); +} + +/** Whether `id` was left open under `storageKey`; a default-open id unless it was closed. */ +export function readStoredExpanded(storageKey: string, id: string, defaultOpen: boolean): boolean { + if (readOpenIds(storageKey).has(id)) return true; + return defaultOpen && !readClosedIds(storageKey).has(id); +} + +/** + * A collapsible whose open state is remembered per tab (AP-8): it starts + * collapsed (or `defaultExpanded`) on the first visit, then as the admin left + * it, also after switching tabs. Expand all / Collapse all, the tab filter + * and deep links (`config:expand`) still apply; while the filter holds it + * open, nothing is remembered. Storage can be unavailable (private mode, + * blocked site data); the state then lasts until the tab unmounts. + */ +export function usePersistentExpanded({ + storageKey, + id, + defaultExpanded = false, + revealPaths, +}: t.PersistentExpandedOptions): t.PersistentExpanded { + const [initial] = useState(() => readStoredExpanded(storageKey, id, defaultExpanded)); + const [isExpanded, setIsExpanded] = useExpandableState(initial, revealPaths); + const [hasEverExpanded, setHasEverExpanded] = useState(isExpanded); + const filter = useRevealingFilter(revealPaths); + const sectionRef = useRef(null); + + useEffect(() => { + if (isExpanded && !hasEverExpanded) setHasEverExpanded(true); + }, [isExpanded, hasEverExpanded]); + + useEffect(() => { + if (filter) return; + writeOpen(storageKey, id, isExpanded); + writeOpen(`${storageKey}:closed`, id, defaultExpanded && !isExpanded); + }, [filter, storageKey, id, isExpanded, defaultExpanded]); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return; + const expand = () => setIsExpanded(true); + const collapse = () => setIsExpanded(false); + el.addEventListener('config:expand', expand); + el.addEventListener('config:collapse', collapse); + return () => { + el.removeEventListener('config:expand', expand); + el.removeEventListener('config:collapse', collapse); + }; + }, [setIsExpanded]); + + const toggle = useCallback(() => setIsExpanded((prev) => !prev), [setIsExpanded]); + + return { isExpanded, hasEverExpanded, sectionRef, toggle }; +} diff --git a/src/hooks/config/field.test.tsx b/src/hooks/config/field.test.tsx index 625fb488..46da53c0 100644 --- a/src/hooks/config/field.test.tsx +++ b/src/hooks/config/field.test.tsx @@ -16,7 +16,6 @@ import { useRecordEntryOverrides, } from './field'; import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; -import { useOptionSources } from './options'; const values: t.ConfigValuesData = { configValues: { interface: { parameters: true } }, @@ -47,13 +46,6 @@ describe('useFieldState', () => { }); }); -describe('useOptionSources', () => { - it('returns no options until WS9 implements the sources', () => { - const { result } = renderHook(() => useOptionSources('endpoints')); - expect(result.current).toEqual({ options: [], isLoading: false }); - }); -}); - describe('useLowerEntryKeys', () => { const withLayers = (layers: t.ValueLayers) => diff --git a/src/hooks/config/index.ts b/src/hooks/config/index.ts index e16a5a19..22d2aab5 100644 --- a/src/hooks/config/index.ts +++ b/src/hooks/config/index.ts @@ -9,6 +9,7 @@ export * from './import'; export * from './keys'; export * from './layers'; export * from './options'; +export * from './accordion'; export * from './reveal'; export * from './review'; export * from './save'; diff --git a/src/hooks/config/options.test.tsx b/src/hooks/config/options.test.tsx new file mode 100644 index 00000000..20552758 --- /dev/null +++ b/src/hooks/config/options.test.tsx @@ -0,0 +1,194 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + useModelSource, + useOptionSources, + useEndpointModels, + buildEndpointOptions, + useModelListDefaults, +} from './options'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; + +vi.mock('@/hooks/useLocalize', () => ({ + useLocalize: () => (key: string) => + key.startsWith('com_config_field_') ? key.slice('com_config_field_'.length).toUpperCase() : key, +})); + +const configValues: t.ConfigRecord = { + endpoints: { custom: [{ name: 'Moonshot' }, { name: '' }, { name: 'Ollama' }] }, + mcpServers: { github: { url: 'https://x' }, files: { command: 'npx' } }, + modelSpecs: { + list: [ + { name: 'a', group: 'Fast' }, + { name: 'b', group: 'Fast' }, + { name: 'c', group: 'Deep' }, + { name: 'd' }, + ], + }, +}; + +const values: t.ConfigValuesData = { configValues, editedValues: {} }; + +function withValues({ children }: { children: ReactNode }) { + return {children}; +} + +function withClient(client: QueryClient) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +const valuesOf = (options: t.SelectOption[]) => options.map((option) => option.value); + +describe('useOptionSources without a query client', () => { + it('derives MCP servers and spec groups from the shown configuration', () => { + const mcp = renderHook(() => useOptionSources('mcpServers'), { wrapper: withValues }); + expect(valuesOf(mcp.result.current.options)).toEqual(['github', 'files']); + const groups = renderHook(() => useOptionSources('specGroups'), { wrapper: withValues }); + expect(valuesOf(groups.result.current.options)).toEqual(['Fast', 'Deep']); + expect(groups.result.current.isLoading).toBe(false); + }); + + it('lists built-in providers and named custom endpoints', () => { + const { result } = renderHook(() => useOptionSources('endpoints'), { wrapper: withValues }); + const options = valuesOf(result.current.options); + expect(options.slice(0, 2)).toEqual(['openAI', 'google']); + expect(options).toContain('Moonshot'); + expect(options).toContain('Ollama'); + expect(options).not.toContain(''); + }); + + it('has no models and does not fetch', () => { + const { result } = renderHook(() => useOptionSources('models:anthropic'), { + wrapper: withValues, + }); + expect(result.current).toEqual({ options: [], isLoading: false }); + }); + + it('returns nothing for no source', () => { + const { result } = renderHook(() => useOptionSources(undefined), { wrapper: withValues }); + expect(result.current.options).toEqual([]); + }); +}); + +describe('useOptionSources with server data', () => { + it("offers an endpoint's models from /api/models", () => { + const client = new QueryClient(); + client.setQueryData(['endpointModels', 0], { anthropic: ['claude-opus-5-5', 'claude-haiku'] }); + const { result } = renderHook(() => useOptionSources('models:anthropic'), { + wrapper: withClient(client), + }); + expect(valuesOf(result.current.options)).toEqual(['claude-opus-5-5', 'claude-haiku']); + const unknown = renderHook(() => useOptionSources('models:nope'), { + wrapper: withClient(client), + }); + expect(unknown.result.current.options).toEqual([]); + }); + + it('lists served endpoints first, then the configured ones it leaves out', () => { + const client = new QueryClient(); + client.setQueryData(['endpointStatus', 0], { + agents: { name: 'agents', custom: false, userProvide: false }, + Moonshot: { name: 'Moonshot', custom: true, userProvide: false }, + }); + const { result } = renderHook(() => useOptionSources('endpoints'), { + wrapper: withClient(client), + }); + const options = valuesOf(result.current.options); + expect(options.slice(0, 3)).toEqual(['agents', 'Moonshot', 'openAI']); + expect(options.filter((name) => name === 'Moonshot')).toHaveLength(1); + }); + + it('exposes the models map for model list defaults', () => { + const client = new QueryClient(); + client.setQueryData(['endpointModels', 0], { bedrock: ['a'] }); + const { result } = renderHook(() => useEndpointModels(), { wrapper: withClient(client) }); + expect(result.current.data).toEqual({ bedrock: ['a'] }); + expect(result.current.isLoading).toBe(false); + }); +}); + +describe('buildEndpointOptions', () => { + it('labels built-in providers with their display name and key', () => { + const options = buildEndpointOptions(undefined, null, (key) => + key === 'com_config_field_openAI' ? 'OpenAI' : key, + ); + expect(options[0]).toEqual({ value: 'openAI', label: 'OpenAI (openAI)' }); + }); +}); + +describe('useModelSource', () => { + const withEndpoint = (endpoint?: string) => { + const data: t.ConfigValuesData = { + configValues: { + endpoints: { + anthropic: { titleEndpoint: endpoint, activityEndpoint: 'Azure Foundry' }, + }, + }, + editedValues: {}, + }; + return ({ children }: { children: ReactNode }) => ( + {children} + ); + }; + + it("suggests the models of the endpoint a model field's sibling names", () => { + const { result } = renderHook( + () => useModelSource('models:anthropic', 'endpoints.anthropic.titleModel'), + { wrapper: withEndpoint('OpenRouter') }, + ); + expect(result.current).toBe('models:OpenRouter'); + }); + + it("falls back to the activity endpoint, then the field's own source", () => { + const phase = renderHook( + () => useModelSource('models:anthropic', 'endpoints.anthropic.activityPhaseModel'), + { wrapper: withEndpoint() }, + ); + expect(phase.result.current).toBe('models:Azure Foundry'); + const title = renderHook( + () => useModelSource('models:anthropic', 'endpoints.anthropic.titleModel'), + { wrapper: withEndpoint(' ') }, + ); + expect(title.result.current).toBe('models:anthropic'); + const other = renderHook(() => useModelSource('endpoints', 'endpoints.anthropic.titleModel'), { + wrapper: withEndpoint('X'), + }); + expect(other.result.current).toBe('endpoints'); + }); +}); + +describe('useModelListDefaults', () => { + it("shows the API's models as defaults only while no list is saved", () => { + const client = new QueryClient(); + client.setQueryData(['endpointModels', 0], { anthropic: ['a', 'b'] }); + const unset = renderHook( + () => useModelListDefaults('endpoints.anthropic.models', 'models:anthropic'), + { wrapper: withClient(client) }, + ); + expect(unset.result.current).toEqual({ defaults: ['a', 'b'], suggestions: ['a', 'b'] }); + + const saved: t.ConfigValuesData = { + configValues: { endpoints: { anthropic: { models: ['a'] } } }, + editedValues: {}, + overridePaths: new Set(['endpoints.anthropic.models']), + }; + const set = renderHook( + () => useModelListDefaults('endpoints.anthropic.models', 'models:anthropic'), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); + expect(set.result.current).toEqual({ suggestions: ['a', 'b'] }); + }); +}); diff --git a/src/hooks/config/options.ts b/src/hooks/config/options.ts index 6079a2a7..b15b63c0 100644 --- a/src/hooks/config/options.ts +++ b/src/hooks/config/options.ts @@ -1,12 +1,235 @@ +import { useContext, useMemo } from 'react'; +import { QueryClient, QueryClientContext, useQuery } from '@tanstack/react-query'; import type * as t from '@/types'; +import { + toolOptionsQuery, + baseConfigOptions, + endpointStatusOptions, + endpointModelsOptions, +} from '@/server'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { getValueAtPath, isConfigRecord } from '@/utils'; +import { BUILT_IN_PROVIDERS } from '@/constants'; +import { useLocalize } from '../useLocalize'; +import { useFieldState } from './field'; -const NO_OPTIONS: t.OptionSourceResult = { options: [], isLoading: false }; +const NO_OPTIONS: t.SelectOption[] = []; +const MODELS_PREFIX = 'models:'; +const BUILT_IN_SET: ReadonlySet = new Set(BUILT_IN_PROVIDERS); + +let inertClient: QueryClient | null = null; + +/** + * The page's query client, or an idle one where none is provided (isolated + * component tests): queries are then disabled and nothing is fetched. + */ +function useClient(): { client: QueryClient; live: boolean } { + const provided = useContext(QueryClientContext); + if (provided) return { client: provided, live: true }; + inertClient ??= new QueryClient(); + return { client: inertClient, live: false }; +} + +/** When the base configuration was last loaded: saves refetch it, and endpoint data follows. */ +function useConfigVersion(client: QueryClient, live: boolean): number { + const { dataUpdatedAt } = useQuery( + { ...baseConfigOptions, enabled: false, notifyOnChangeProps: ['dataUpdatedAt'] }, + client, + ); + return live ? dataUpdatedAt : 0; +} + +/** Enabled endpoints from LibreChat (`/api/endpoints`), refetched after each save. */ +export function useEndpointStatus(): t.QueryState { + const { client, live } = useClient(); + const version = useConfigVersion(client, live); + const query = useQuery( + { ...endpointStatusOptions(version), enabled: live, placeholderData: (prev) => prev }, + client, + ); + return { data: query.data, isLoading: live && query.isLoading, isError: query.isError }; +} + +/** Model names per endpoint from LibreChat (`/api/models`), refetched after each save. */ +export function useEndpointModels(enabled = true): t.QueryState { + const { client, live } = useClient(); + const version = useConfigVersion(client, live); + const query = useQuery( + { + ...endpointModelsOptions(version), + enabled: live && enabled, + placeholderData: (prev) => prev, + }, + client, + ); + return { + data: query.data, + isLoading: live && enabled && query.isLoading, + isError: query.isError, + }; +} + +/** Custom endpoint names in the shown configuration (pending edits included). */ +function customEndpointNames(root: t.ConfigValue): string[] { + const list = getValueAtPath(root, 'endpoints.custom'); + if (!Array.isArray(list)) return []; + const names: string[] = []; + for (const entry of list) { + const name = isConfigRecord(entry) ? entry.name : undefined; + if (typeof name === 'string' && name.trim() !== '') names.push(name); + } + return names; +} + +/** + * Endpoint choices: the endpoints LibreChat serves (built-in providers named + * "OpenAI (openAI)"), then built-in providers and custom endpoints the + * configuration defines but LibreChat does not serve (not enabled, or not + * saved yet), so a reference to any of them is recognized. + */ +export function buildEndpointOptions( + status: t.EndpointStatusMap | undefined, + root: t.ConfigValue, + localize: t.LocalizeFn, +): t.SelectOption[] { + const seen = new Set(); + const options: t.SelectOption[] = []; + const add = (name: string) => { + if (seen.has(name)) return; + seen.add(name); + const label = BUILT_IN_SET.has(name) ? localize(`com_config_field_${name}`) : name; + options.push({ value: name, label: label === name ? name : `${label} (${name})` }); + }; + for (const name of Object.keys(status ?? {})) add(name); + for (const name of BUILT_IN_PROVIDERS) add(name); + for (const name of customEndpointNames(root)) add(name); + return options; +} + +function recordKeys(root: t.ConfigValue, path: string): string[] { + const value = getValueAtPath(root, path); + return isConfigRecord(value) ? Object.keys(value) : []; +} + +/** Distinct `group` names of the model specs list. */ +function specGroups(root: t.ConfigValue): string[] { + const list = getValueAtPath(root, 'modelSpecs.list'); + if (!Array.isArray(list)) return []; + const groups = new Set(); + for (const spec of list) { + const group = isConfigRecord(spec) ? spec.group : undefined; + if (typeof group === 'string' && group.trim() !== '') groups.add(group); + } + return [...groups]; +} + +function toOptions(values: string[]): t.SelectOption[] { + return values.map((value) => ({ value, label: value })); +} + +/** + * Options for a reference select (WS9; consumed by WS3's RefSelectField and + * the area editors): + * - `endpoints`: endpoints LibreChat serves, plus every configured one; + * - `models:`: that endpoint's models from `/api/models`; + * - `mcpServers`: MCP server names in the shown configuration; + * - `specGroups`: group names used by the model specs list; + * - `tools`: tools agents can use (`/api/agents/tools`). + * Configuration-derived sources include pending edits. Server data is + * refetched after each save; outside a query client only the configuration + * applies. + */ +export function useOptionSources(source?: t.OptionSource): t.OptionSourceResult { + const localize = useLocalize(); + const values = useContext(ConfigValuesContext); + const root = values?.configValues ?? values?.layers?.effective ?? null; + const { client, live } = useClient(); + const version = useConfigVersion(client, live); + const wantsStatus = source === 'endpoints'; + const wantsModels = source?.startsWith(MODELS_PREFIX) ?? false; + const wantsTools = source === 'tools'; + const status = useQuery( + { + ...endpointStatusOptions(version), + enabled: live && wantsStatus, + placeholderData: (prev) => prev, + }, + client, + ); + const models = useQuery( + { + ...endpointModelsOptions(version), + enabled: live && wantsModels, + placeholderData: (prev) => prev, + }, + client, + ); + const tools = useQuery({ ...toolOptionsQuery, enabled: live && wantsTools }, client); + + const options = useMemo((): t.SelectOption[] => { + if (!source) return NO_OPTIONS; + if (source === 'endpoints') return buildEndpointOptions(status.data, root, localize); + 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(models.data?.[endpoint] ?? []); + }, [source, status.data, models.data, tools.data, root, localize]); + + const isLoading = + live && + ((wantsStatus && status.isLoading) || + (wantsModels && models.isLoading) || + (wantsTools && tools.isLoading)); + return { options, isLoading }; +} + +/** + * Defaults and suggestions for a model list field (AP-2): the models + * `/api/models` lists for the source's endpoint. They are the provider's + * defaults only while the saved configuration sets no list (the API returns + * the configured list otherwise), so `defaults` is left out then. + */ +export function useModelListDefaults( + path: string, + source: t.OptionSource | undefined, +): t.ModelListDefaults { + const endpoint = source?.startsWith(MODELS_PREFIX) ? source.slice(MODELS_PREFIX.length) : null; + const models = useEndpointModels(endpoint != null); + const state = useFieldState(path); + return useMemo(() => { + const list = endpoint ? models.data?.[endpoint] : undefined; + if (!list) return { suggestions: [] }; + const savedList = state.isOverride || Array.isArray(state.effective); + return savedList ? { suggestions: list } : { defaults: list, suggestions: list }; + }, [endpoint, models.data, state.isOverride, state.effective]); +} + +/** Siblings that name the endpoint a model field's model runs on, nearest first. */ +const MODEL_ENDPOINT_FIELDS: Readonly> = { + titleModel: ['titleEndpoint'], + activityModel: ['activityEndpoint'], + activityPhaseModel: ['activityPhaseEndpoint', 'activityEndpoint'], + reasoningLabelModel: ['reasoningLabelEndpoint', 'activityEndpoint'], +}; /** - * Options for a reference select: configured endpoints, an endpoint's models, - * MCP servers, spec groups or tools (stub: no options yet; WS9 implements it, - * WS3's RefSelectField consumes it). + * The option source of a model field (AP-9): the models of the endpoint its + * sibling names (`titleModel` → `titleEndpoint`), pending edits included, else + * `source` (the field's own provider). Other sources pass through. */ -export function useOptionSources(_source?: t.OptionSource): t.OptionSourceResult { - return NO_OPTIONS; +export function useModelSource( + source: t.OptionSource | undefined, + path: string | undefined, +): t.OptionSource | undefined { + const values = useContext(ConfigValuesContext); + const root = values?.configValues ?? values?.layers?.effective ?? null; + if (!path || !source?.startsWith(MODELS_PREFIX)) return source; + const dot = path.lastIndexOf('.'); + const siblings = MODEL_ENDPOINT_FIELDS[path.slice(dot + 1)] ?? []; + for (const sibling of siblings) { + const endpoint = getValueAtPath(root, `${path.slice(0, dot)}.${sibling}`); + if (typeof endpoint === 'string' && endpoint.trim() !== '') return `models:${endpoint}`; + } + return source; } diff --git a/src/hooks/config/sections.ts b/src/hooks/config/sections.ts index 93fc6f19..b029af00 100644 --- a/src/hooks/config/sections.ts +++ b/src/hooks/config/sections.ts @@ -7,7 +7,7 @@ import { compareSections, fallbackSectionTitle, } from '@/components/configuration/configMeta'; -import { labelForPath } from '@/components/configuration/meta'; +import { providerTocItems } from '@/components/configuration/sections/providers'; import { getValueAtPath, isConfigRecord } from '@/utils'; import { READ_ONLY_CONFIG_SECTIONS } from '@/constants'; import { useLocalize } from '../useLocalize'; @@ -88,14 +88,7 @@ function collectionTocItems( })); } if (activeTab === 'providers' && section.schemaKey === 'endpoints') { - return section.fields - .filter((f) => f.key !== 'custom' && f.children && f.children.length > 0) - .map((f) => ({ - id: `section-${dataKey}.${f.key}`, - label: labelForPath(`${dataKey}.${f.key}`, localize), - dataPath: `${dataKey}.${f.key}`, - path: `${dataKey}.${f.key}`, - })); + return providerTocItems(section.fields, dataKey, localize); } return undefined; } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 611cc961..a1e1ada8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -430,6 +430,230 @@ "com_config_section_endpoints_desc": "OpenAI-compatible providers, defined in librechat.yaml or added here.", "com_config_section_ai_providers": "AI providers", "com_config_section_ai_providers_desc": "Built-in AI provider connections", + "com_config_providers_acap_actions": "Actions", + "com_config_providers_acap_actions_desc": "Call external APIs described by OpenAPI specs.", + "com_config_providers_acap_code_interpreter": "Code interpreter", + "com_config_providers_acap_code_interpreter_desc": "Run code in OpenAI's sandbox.", + "com_config_providers_acap_image_vision": "Image vision", + "com_config_providers_acap_image_vision_desc": "Understand images users upload.", + "com_config_providers_acap_retrieval": "Retrieval", + "com_config_providers_acap_retrieval_desc": "Search the files attached to an assistant.", + "com_config_providers_acap_tools": "Tools", + "com_config_providers_acap_tools_desc": "Use built-in tools.", + "com_config_providers_azure_close": "Close", + "com_config_providers_azure_col_assistants": "Assistants", + "com_config_providers_azure_col_group": "Group", + "com_config_providers_azure_col_models": "Models", + "com_config_providers_azure_col_serverless": "Serverless", + "com_config_providers_azure_col_target": "Instance or base URL", + "com_config_providers_azure_col_version": "API version", + "com_config_providers_azure_details": "Details", + "com_config_providers_azure_details_aria": "Details of Azure group {{group}}", + "com_config_providers_azure_drawer_desc": "Read-only. Change this group in librechat.yaml.", + "com_config_providers_azure_empty": "No Azure groups are defined in librechat.yaml.", + "com_config_providers_azure_env": "From {{name}} (server environment)", + "com_config_providers_azure_field_addParams": "Added parameters", + "com_config_providers_azure_field_apiKey": "API key", + "com_config_providers_azure_field_assistants": "Assistants", + "com_config_providers_azure_field_baseURL": "Base URL", + "com_config_providers_azure_field_deployment": "Deployment name", + "com_config_providers_azure_field_dropParams": "Dropped parameters", + "com_config_providers_azure_field_group": "Group", + "com_config_providers_azure_field_headers": "Additional headers", + "com_config_providers_azure_field_instance": "Instance name", + "com_config_providers_azure_field_serverless": "Serverless", + "com_config_providers_azure_field_version": "API version", + "com_config_providers_azure_inherits": "Group default", + "com_config_providers_azure_masked": "Set (hidden)", + "com_config_providers_azure_model_col_deployment": "Deployment", + "com_config_providers_azure_model_col_name": "Model", + "com_config_providers_azure_model_col_version": "API version", + "com_config_providers_azure_models_empty": "This group lists no models.", + "com_config_providers_azure_models_title": "Models", + "com_config_providers_azure_no": "No", + "com_config_providers_azure_none": "Not set", + "com_config_providers_azure_readonly": "Azure groups are managed in librechat.yaml. LibreChat does not apply admin overrides to them yet, so they are shown read-only.", + "com_config_providers_azure_serverless_on": "Serverless", + "com_config_providers_azure_stale": "A stored override for Azure groups exists. LibreChat ignores it; remove it so it cannot take effect later.", + "com_config_providers_azure_yes": "Yes", + "com_config_providers_cap_actions": "Actions", + "com_config_providers_cap_actions_desc": "Call external APIs described by OpenAPI specs.", + "com_config_providers_cap_artifacts": "Artifacts", + "com_config_providers_cap_artifacts_desc": "Create interactive content such as React components, HTML pages and diagrams.", + "com_config_providers_cap_ask_user_question": "Ask user", + "com_config_providers_cap_ask_user_question_desc": "Pause to ask the user a question, then continue with the answer.", + "com_config_providers_cap_chain": "Chain (retired)", + "com_config_providers_cap_chain_desc": "Agent chains are retired; kept for older configurations.", + "com_config_providers_cap_context": "File context", + "com_config_providers_cap_context_desc": "Read uploaded files into the conversation as context.", + "com_config_providers_cap_deferred_tools": "Deferred tools", + "com_config_providers_cap_deferred_tools_desc": "Load rarely used tools on demand instead of sending every tool to the model.", + "com_config_providers_cap_end_after_tools": "End after tools", + "com_config_providers_cap_end_after_tools_desc": "Let an agent end its run right after a tool call, without a final response.", + "com_config_providers_cap_execute_code": "Code execution", + "com_config_providers_cap_execute_code_desc": "Run code in a sandbox through the Code Interpreter API.", + "com_config_providers_cap_file_search": "File search", + "com_config_providers_cap_file_search_desc": "Search the content of uploaded files and cite it.", + "com_config_providers_cap_hide_sequential_outputs": "Hide sequential outputs", + "com_config_providers_cap_hide_sequential_outputs_desc": "In multi-agent runs, show users only the final agent's response.", + "com_config_providers_cap_memory": "Memory", + "com_config_providers_cap_memory_desc": "Remember facts about users across conversations.", + "com_config_providers_cap_ocr": "OCR", + "com_config_providers_cap_ocr_desc": "Read text from images and scanned documents.", + "com_config_providers_cap_programmatic_tools": "Programmatic tools", + "com_config_providers_cap_programmatic_tools_desc": "Call tools from code the agent runs (needs a recent Code Interpreter API).", + "com_config_providers_cap_run_in_background": "Background tools", + "com_config_providers_cap_run_in_background_desc": "Run tools marked for it in the background while the agent continues.", + "com_config_providers_cap_skills": "Skills", + "com_config_providers_cap_skills_desc": "Use reusable skills from the skills catalog.", + "com_config_providers_cap_stateful_code_sessions": "Stateful code sessions", + "com_config_providers_cap_stateful_code_sessions_desc": "Keep a persistent code workspace across the turns of a conversation.", + "com_config_providers_cap_subagents": "Subagents", + "com_config_providers_cap_subagents_desc": "Delegate tasks to other agents.", + "com_config_providers_cap_tool_intents": "Tool intent labels", + "com_config_providers_cap_tool_intents_desc": "Show a one-sentence status for each tool call, written by the model.", + "com_config_providers_cap_tools": "Tools", + "com_config_providers_cap_tools_desc": "Use built-in tools and MCP server tools.", + "com_config_providers_cap_web_search": "Web search", + "com_config_providers_cap_web_search_desc": "Search the web and cite the results.", + "com_config_providers_creds_agents": "Always available. Each agent uses the credentials of the provider it runs on.", + "com_config_providers_creds_anthropic": "Credentials come from ANTHROPIC_API_KEY in the server environment, or from Vertex AI when ANTHROPIC_USE_VERTEX is set.", + "com_config_providers_creds_assistants": "Credentials come from ASSISTANTS_API_KEY in the server environment; ASSISTANTS_BASE_URL sets its base URL.", + "com_config_providers_creds_azureAssistants": "Credentials come from AZURE_ASSISTANTS_API_KEY, or from the Azure groups that have Assistants turned on.", + "com_config_providers_creds_azureOpenAI": "Credentials come from the API keys of the Azure groups in librechat.yaml (or AZURE_API_KEY).", + "com_config_providers_creds_bedrock": "Credentials come from the BEDROCK_AWS_* variables (or BEDROCK_AWS_DEFAULT_REGION) in the server environment.", + "com_config_providers_creds_google": "Credentials come from GOOGLE_KEY, or a Google service account key file, in the server environment.", + "com_config_providers_creds_openAI": "Credentials come from OPENAI_API_KEY in the server environment; OPENAI_REVERSE_PROXY sets its base URL.", + "com_config_providers_customized": "{{count}} customized", + "com_config_providers_desc_activityPhaseEndpoint": "Endpoint whose credentials run the phase summary model. Defaults to the activity endpoint.", + "com_config_providers_desc_activityPhaseModel": "Model that writes phase summaries. Defaults to the activity model, then the title model.", + "com_config_providers_desc_activityPhasePrompt": "Replaces the instructions used to write phase summaries.", + "com_config_providers_desc_agents_backgroundTasks_completionResultBatchSize": "Most finished background results delivered to the agent together.", + "com_config_providers_desc_agents_backgroundTasks_completionResultMaxChars": "Most characters of a background task's output passed back to the agent.", + "com_config_providers_desc_agents_backgroundTasks_completionWakeups": "Wake the conversation when a background task finishes, instead of waiting for the agent to check.", + "com_config_providers_desc_agents_backgroundTasks_ordinaryToolCancellation": "Let users cancel ordinary tools that run in the background.", + "com_config_providers_desc_agents_checkpointer_collection": "MongoDB collection for checkpoints. Change it only if the default name is taken.", + "com_config_providers_desc_agents_checkpointer_ttl": "How long a paused run waits for a decision before it is removed. Default 86,400 seconds (24 hours).", + "com_config_providers_desc_agents_checkpointer_type": "MongoDB keeps paused runs across restarts and replicas; in-memory keeps them only in this process (development only).", + "com_config_providers_desc_agents_codeApiMaxRetryWaitMs": "Longest time spent waiting on Code API rate limits for one operation.", + "com_config_providers_desc_agents_codeApiUploadConcurrency": "Most Code API uploads that run at the same time for one user.", + "com_config_providers_desc_agents_eventDriven_selfUrl": "Trusted URL of this LibreChat server, used to deliver agent events in-process.", + "com_config_providers_desc_agents_fileSharing_allowSiblingSharing": "Also let subagents of the same run share files with each other.", + "com_config_providers_desc_agents_fileSharing_enabled": "Let agents share files with the subagents they delegate to, when the delegation opts in.", + "com_config_providers_desc_agents_fileSharing_maxFiles": "Most files shared in one run.", + "com_config_providers_desc_agents_fileSharing_maxPrivateBytes": "Total size of private file versions kept during one run.", + "com_config_providers_desc_agents_fileSharing_ttlMs": "How long shared files stay available.", + "com_config_providers_desc_agents_maxDeltaEventsPerTurn": "Most streamed chunks one model response may produce before the run stops. Off unless set.", + "com_config_providers_desc_agents_maxRetainedToolCountChars": "Characters of tool output counted exactly for the context gauge when a turn stops at the tool-call limit.", + "com_config_providers_desc_agents_maxToolCallArgBytes": "Largest size a single streamed tool call's arguments may reach before the run stops. 0 turns the check off; the default is 64 KB.", + "com_config_providers_desc_agents_maxToolCallArgBytesByTool": "Per-tool exceptions to the tool argument size limit, keyed by tool name. 0 turns the check off for that tool.", + "com_config_providers_desc_agents_repositoryInstructions_timeoutMs": "Longest time spent reading repository instructions (such as AGENTS.md) from a code workspace.", + "com_config_providers_desc_agents_retainedAnswers_enabled": "Keep the user's answers to agent questions in the run context, even after older messages are summarized or dropped.", + "com_config_providers_desc_agents_retainedAnswers_maxTokens": "Most tokens of kept answers; older answers are dropped first.", + "com_config_providers_desc_agents_skills_maxCatalogSkills": "Most skills listed to the model at once.", + "com_config_providers_desc_assistants_disableBuilder": "Hide the assistant builder, so users can't create or edit assistants.", + "com_config_providers_desc_availableRegions": "AWS regions users can pick for Bedrock requests.", + "com_config_providers_desc_azureAssistants": "Offer Azure OpenAI Assistants, using the groups that have Assistants turned on.", + "com_config_providers_desc_guardrailIdentifier": "ID or ARN of the Bedrock guardrail.", + "com_config_providers_desc_guardrailStreamMode": "Whether streamed responses are checked as they stream (async) or before they are shown (sync).", + "com_config_providers_desc_guardrailTrace": "Whether Bedrock returns the guardrail's trace with responses.", + "com_config_providers_desc_guardrailVersion": "Version of the guardrail to apply, or DRAFT.", + "com_config_providers_desc_models": "Models users can choose for this provider. Unset, LibreChat uses the provider's built-in or fetched list.", + "com_config_providers_desc_reasoningLabelEndpoint": "Endpoint whose credentials run the reasoning label model. Defaults to the activity endpoint.", + "com_config_providers_desc_reasoningLabelMinChars": "Characters of reasoning needed before the first label is written.", + "com_config_providers_desc_reasoningLabelPrompt": "Replaces the instructions used to write reasoning labels.", + "com_config_providers_desc_reasoningLabelUpdateChars": "New characters of reasoning needed before the label is revised.", + "com_config_providers_desc_reasoningLabelUpdateIntervalMs": "Shortest time between two revisions of the label.", + "com_config_providers_desc_unused_apiKey": "Not used: LibreChat reads the Assistants API key from the server environment (ASSISTANTS_API_KEY).", + "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": "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)", + "com_config_providers_global_titles_banner": "Global overrides exist, so LibreChat reads title settings only from them. The title settings below are ignored.", + "com_config_providers_global_titles_link": "Go to the Global title settings", + "com_config_providers_group_activity": "Activity labels", + "com_config_providers_group_activity_desc": "Short generated headers that summarize each block of reasoning and tool calls while a response streams.", + "com_config_providers_group_advanced": "Advanced", + "com_config_providers_group_advanced_desc": "Extra HTTP headers, and settings that are newer than this panel.", + "com_config_providers_group_apis": "Management & remote APIs", + "com_config_providers_group_apis_desc": "Sign-in for the agent management API and for the remote agents API.", + "com_config_providers_group_approval": "Tool approval", + "com_config_providers_group_approval_desc": "Human review of tool calls before agents run them, and the answers users give to agent questions.", + "com_config_providers_group_assistants": "Assistants", + "com_config_providers_group_assistants_desc": "What assistants can do and which assistants users see.", + "com_config_providers_group_azure": "Azure groups", + "com_config_providers_group_azure_desc": "Azure OpenAI resources and the model deployments each one serves.", + "com_config_providers_group_background": "Background tasks", + "com_config_providers_group_background_desc": "How agents deliver the results of tools and subagents that run in the background, and event-driven runs.", + "com_config_providers_group_checkpointer": "Checkpointer", + "com_config_providers_group_checkpointer_desc": "Where paused runs (tool approval, Ask user) are kept until someone resumes them.", + "com_config_providers_group_citations": "Citations & file search", + "com_config_providers_group_citations_desc": "How many file-search citations a response includes, and how relevant they must be.", + "com_config_providers_group_code": "Code execution", + "com_config_providers_group_code_desc": "Code API uploads and retries, repository instructions, file sharing between agents, and stateful code sessions.", + "com_config_providers_group_features": "Agent features", + "com_config_providers_group_features_desc": "What agents can do, who can build them, and how many steps a run may take.", + "com_config_providers_group_guardrails": "Guardrails", + "com_config_providers_group_guardrails_desc": "An Amazon Bedrock guardrail applied to every request.", + "com_config_providers_group_limits": "Runtime limits & timeouts", + "com_config_providers_group_limits_desc": "Safety limits on subagents, streamed output, tool arguments and provider responses.", + "com_config_providers_group_models": "Models", + "com_config_providers_group_models_bedrock_desc": "Which models and AWS regions users can choose, and inference profiles for cross-region models.", + "com_config_providers_group_models_desc": "Which models users can choose. Leave the list on the provider's defaults to keep the built-in or fetched model list.", + "com_config_providers_group_reasoning": "Reasoning labels", + "com_config_providers_group_reasoning_desc": "A live one-line label that summarizes long model reasoning while it streams.", + "com_config_providers_group_streaming": "Streaming & limits", + "com_config_providers_group_streaming_desc": "How fast responses stream, and how much of each tool result the model sees.", + "com_config_providers_group_titles": "Title generation", + "com_config_providers_group_titles_desc": "How conversation titles are written: whether to generate them, and with which model, prompt and method.", + "com_config_providers_group_unused": "Not used by built-in providers", + "com_config_providers_group_unused_desc": "LibreChat reads these from the server environment for built-in providers, so values set here have no effect. They stay listed so existing values remain visible.", + "com_config_providers_groups_one": "{{count}} group", + "com_config_providers_groups_other": "{{count}} groups", + "com_config_providers_network_addresses": "Allowed private addresses", + "com_config_providers_network_addresses_desc": "One host:port per entry, for example localhost:11434 for a local Ollama, or [::1]:8080. A hostname must resolve to a private address.", + "com_config_providers_network_desc": "LibreChat blocks requests from AI providers and custom endpoints to private network addresses, to protect your network. List the private services they may reach.", + "com_config_providers_network_issue_empty": "Enter a host and a port.", + "com_config_providers_network_issue_format": "Use host:port, for example localhost:11434 or [::1]:11434.", + "com_config_providers_network_issue_port": "Add a port, for example localhost:11434.", + "com_config_providers_network_issue_public": "Public IP addresses are not allowed. Enter a private address, or a hostname that resolves to one.", + "com_config_providers_network_issue_url": "Enter only host:port, without http:// or a path.", + "com_config_providers_network_item": "address", + "com_config_providers_network_placeholder": "host:port, e.g. localhost:11434", + "com_config_providers_network_title": "Network access", + "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_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", + "com_config_providers_shadow_label_value": "Overridden by Global: {{value}}", + "com_config_providers_status_active": "Active", + "com_config_providers_status_active_desc": "LibreChat serves this provider.", + "com_config_providers_status_disabled": "Not enabled", + "com_config_providers_status_disabled_desc": "LibreChat does not serve this provider: its credentials are missing, or the ENDPOINTS variable leaves it out. Settings here take effect once it is enabled.", + "com_config_providers_status_endpoints_note": "The ENDPOINTS environment variable can also limit which providers are enabled.", + "com_config_providers_status_global": "Applies to every provider and custom endpoint", + "com_config_providers_status_label": "Status", + "com_config_providers_status_models_one": "{{count}} model available", + "com_config_providers_status_models_other": "{{count}} models available", + "com_config_providers_status_unknown": "Status unknown", + "com_config_providers_status_unknown_desc": "The provider status could not be loaded from LibreChat.", + "com_config_providers_status_userProvided": "Users bring keys", + "com_config_providers_status_userProvided_desc": "LibreChat serves this provider, and each user enters their own key in the chat.", + "com_config_providers_summary_global_titles": "Not set: every endpoint uses the title defaults, and per-endpoint title settings are ignored", + "com_config_providers_summary_ignored": "Ignored while Global overrides exist", + "com_config_providers_summary_item": "item", + "com_config_providers_summary_keys": "{{count}} set", + "com_config_providers_summary_more": "{{shown}} +{{count}}", + "com_config_providers_summary_none": "None", + "com_config_providers_summary_pair": "{{label}}: {{value}}", + "com_config_providers_summary_rest": "+{{count}} more", + "com_config_providers_summary_selected": "{{count}} on", + "com_config_providers_summary_set": "Set here: {{summary}}", + "com_config_providers_summary_shadowed": "{{value}} (Global wins)", + "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", "com_config_section_memory_desc": "Personalization settings", "com_config_section_mcp_settings": "MCP settings", diff --git a/src/server/config.test.ts b/src/server/config.test.ts index bc6e2b8d..a6ec88e7 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -821,7 +821,7 @@ function sampleValueForControl(field: t.SchemaField, path?: string): unknown { case 'scalar-union': return true; case 'model-list': - return ['item', { name: 'item-2' }]; + return getArrayItemType(field.type) === 'string' ? ['item'] : ['item', { name: 'item-2' }]; case 'array': case 'multi-enum': { const itemType = getArrayItemType(field.type); diff --git a/src/server/config/normalize.test.ts b/src/server/config/normalize.test.ts new file mode 100644 index 00000000..af3185ef --- /dev/null +++ b/src/server/config/normalize.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import type * as t from '@/types'; +import { normalizeAppServiceKeys, normalizeEndpointValue } from './normalize'; + +/** An `azureOpenAI` block as LibreChat's `azureConfigSetup` returns it. */ +const processedAzure = { + errors: [], + isValid: true, + groupMap: { + eastus: { + apiKey: '${EASTUS_API_KEY}', + instanceName: 'librechat-eastus', + version: '2024-02-15-preview', + models: { 'gpt-4.1': { deploymentName: 'gpt-4.1' } }, + serverless: false, + assistants: true, + }, + foundry: { apiKey: 'sk-literal', baseURL: 'https://x/models', serverless: true, models: {} }, + }, + modelNames: ['gpt-4.1'], + modelGroupMap: { 'gpt-4.1': { group: 'eastus' } }, + assistantModels: ['gpt-4.1'], + assistantGroups: ['eastus'], + serverless: false, + instanceName: '', + deploymentName: '', + streamRate: 25, + assistants: true, +}; + +describe('normalizeEndpointValue', () => { + it('drops every key Azure setup derives, assistantGroups included', () => { + const normalized = normalizeEndpointValue(processedAzure); + expect(Object.keys(normalized as object).sort()).toEqual([ + 'assistants', + 'groups', + 'streamRate', + ]); + }); + + it('rebuilds groups from the live groupMap, keeping dotted model names', () => { + const normalized = normalizeEndpointValue(processedAzure) as { groups: object[] }; + expect(normalized.groups).toEqual([ + { + group: 'eastus', + apiKey: '${EASTUS_API_KEY}', + instanceName: 'librechat-eastus', + version: '2024-02-15-preview', + models: { 'gpt-4.1': { deploymentName: 'gpt-4.1' } }, + serverless: false, + assistants: true, + }, + { + group: 'foundry', + apiKey: 'sk-literal', + baseURL: 'https://x/models', + serverless: true, + models: {}, + }, + ]); + }); + + it('shows the live groups over a stored override LibreChat does not apply (D13)', () => { + const merged = { ...processedAzure, groups: [{ group: 'stale', apiKey: 'x', models: {} }] }; + const normalized = normalizeEndpointValue(merged) as { groups: Array<{ group: string }> }; + expect(normalized.groups.map((group) => group.group)).toEqual(['eastus', 'foundry']); + }); + + it("drops the Anthropic block's derived vertexConfig and keeps vertex", () => { + const anthropic = { + vertex: { enabled: true, projectId: 'p' }, + vertexConfig: { modelNames: ['claude'] }, + streamRate: 30, + }; + expect(normalizeEndpointValue(anthropic, 'anthropic')).toEqual({ + vertex: { enabled: true, projectId: 'p' }, + streamRate: 30, + }); + }); + + it('leaves other values unchanged', () => { + const openAI = { titleModel: 'gpt-5-mini' }; + expect(normalizeEndpointValue(openAI, 'openAI')).toBe(openAI); + expect(normalizeEndpointValue(['a'])).toEqual(['a']); + expect(normalizeEndpointValue(null)).toBeNull(); + }); +}); + +describe('normalizeAppServiceKeys', () => { + it('normalizes every endpoint block by its key', () => { + const result = normalizeAppServiceKeys({ + endpoints: { + azureOpenAI: processedAzure, + anthropic: { vertexConfig: {}, models: ['a'] }, + }, + }); + const endpoints = result.endpoints as Record; + expect(endpoints.azureOpenAI.groupMap).toBeUndefined(); + expect(endpoints.anthropic).toEqual({ models: ['a'] }); + }); +}); diff --git a/src/server/config/normalize.ts b/src/server/config/normalize.ts index 054a972c..10ab34f6 100644 --- a/src/server/config/normalize.ts +++ b/src/server/config/normalize.ts @@ -2,6 +2,7 @@ import type * as t from '@/types'; import { stripInterfacePermissionFields } from '@/utils'; import { APP_SERVICE_KEY_ALIASES } from '@/constants'; +/** Keys LibreChat's `azureConfigSetup` derives from `groups`; they are not settings. */ const AZURE_OPENAI_DERIVED_KEYS = new Set([ 'assistantGroups', 'errors', @@ -15,27 +16,49 @@ const AZURE_OPENAI_DERIVED_KEYS = new Set([ 'deploymentName', ]); -function normalizeEndpointValue(value: t.ConfigValue): t.ConfigValue { - if (!value || typeof value !== 'object' || Array.isArray(value)) return value; - const obj = value as Record; - if ('groupMap' in obj && 'isValid' in obj) { - const groupMap = obj.groupMap; - const cleaned: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (AZURE_OPENAI_DERIVED_KEYS.has(k)) continue; - cleaned[k] = v; - } - if (groupMap && typeof groupMap === 'object' && !Array.isArray(groupMap)) { - cleaned.groups = Object.entries(groupMap as Record).map( - ([group, config]) => ({ - group, - ...(typeof config === 'object' && config !== null && !Array.isArray(config) - ? (config as Record) - : {}), - }), - ); - } - return cleaned; +/** Keys LibreChat's endpoint setup adds to the Anthropic block (`vertexConfig` from `vertex`). */ +const ANTHROPIC_DERIVED_KEYS = new Set(['vertexConfig']); + +function isRecord(value: t.ConfigValue): value is t.ConfigRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function withoutKeys(value: t.ConfigRecord, keys: ReadonlySet): t.ConfigRecord { + const cleaned: t.ConfigRecord = {}; + for (const [key, child] of Object.entries(value)) { + if (!keys.has(key)) cleaned[key] = child; + } + return cleaned; +} + +/** + * The Azure OpenAI block as configured: derived keys dropped and `groups` + * rebuilt from `groupMap`, which is what LibreChat serves. A stored `groups` + * override is not re-processed by LibreChat (it only runs `azureConfigSetup` + * on librechat.yaml), so the live `groupMap` wins over it (D13): the table + * shows what users get, and the stale override is flagged separately. + */ +function normalizeAzureOpenAI(value: t.ConfigRecord): t.ConfigRecord { + const cleaned = withoutKeys(value, AZURE_OPENAI_DERIVED_KEYS); + const { groupMap } = value; + if (!isRecord(groupMap)) return cleaned; + cleaned.groups = Object.entries(groupMap).map(([group, config]) => ({ + group, + ...(isRecord(config) ? config : {}), + })); + return cleaned; +} + +/** + * One endpoint block of LibreChat's processed AppConfig, mapped back to the + * configuration it came from: the processed Azure OpenAI block (`groupMap` + * and `isValid` present) and the Anthropic block's `vertexConfig`. + */ +export function normalizeEndpointValue(value: t.ConfigValue, key?: string): t.ConfigValue { + if (!isRecord(value)) return value; + if ('groupMap' in value && 'isValid' in value) return normalizeAzureOpenAI(value); + if (key === 'anthropic' && 'vertexConfig' in value) { + return withoutKeys(value, ANTHROPIC_DERIVED_KEYS); } return value; } @@ -48,24 +71,16 @@ export function normalizeAppServiceKeys( for (const [key, value] of Object.entries(raw)) { result[APP_SERVICE_KEY_ALIASES[key] ?? key] = value; } - if ( - result.interface && - typeof result.interface === 'object' && - !Array.isArray(result.interface) - ) { + if (isRecord(result.interface)) { result.interface = stripInterfacePermissionFields( result.interface as Record, ) as t.ConfigValue; } - if ( - result.endpoints && - typeof result.endpoints === 'object' && - !Array.isArray(result.endpoints) - ) { - const endpoints = { ...(result.endpoints as Record) }; + if (isRecord(result.endpoints)) { + const endpoints = { ...result.endpoints }; result.endpoints = endpoints; for (const [epKey, epValue] of Object.entries(endpoints)) { - endpoints[epKey] = normalizeEndpointValue(epValue); + endpoints[epKey] = normalizeEndpointValue(epValue, epKey); } } return result; diff --git a/src/server/endpoints.test.ts b/src/server/endpoints.test.ts new file mode 100644 index 00000000..629b06f2 --- /dev/null +++ b/src/server/endpoints.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetchMock = vi.fn(); +const userAgentMock = vi.fn(); + +vi.mock('./utils/api', () => ({ + apiFetch: (path: string, init?: RequestInit) => apiFetchMock(path, init), +})); + +vi.mock('@tanstack/react-start/server', () => ({ + getRequestHeader: (name: string) => userAgentMock(name), +})); + +vi.mock('@tanstack/react-start', () => ({ + createServerFn: () => ({ + handler: (fn: (...args: never[]) => unknown) => fn, + }), +})); + +import { + getToolsFn, + getModelsFn, + toToolOptions, + getEndpointStatusFn, + toEndpointModelsMap, + toEndpointStatusMap, +} from './endpoints'; + +const json = (body: object, status = 200) => new Response(JSON.stringify(body), { status }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('toEndpointStatusMap', () => { + it('keeps the endpoints LibreChat serves, marking custom and user-provided ones', () => { + const map = toEndpointStatusMap({ + openAI: { userProvide: false, order: 0 }, + Moonshot: { type: 'custom', userProvide: true, modelDisplayLabel: 'Kimi' }, + broken: 'nope', + }); + expect(map).toEqual({ + openAI: { name: 'openAI', custom: false, userProvide: false }, + Moonshot: { name: 'Moonshot', custom: true, userProvide: true, label: 'Kimi' }, + }); + }); + + it('treats a non-object payload as no endpoints', () => { + expect(toEndpointStatusMap(null)).toEqual({}); + expect(toEndpointStatusMap(['openAI'])).toEqual({}); + }); +}); + +describe('toEndpointModelsMap', () => { + it('drops blank, duplicate and non-string model names', () => { + expect( + toEndpointModelsMap({ + anthropic: ['claude-opus-5-5', '', 'claude-opus-5-5', 3, ' '], + google: 'not a list', + }), + ).toEqual({ anthropic: ['claude-opus-5-5'] }); + }); +}); + +describe('toToolOptions', () => { + it('values tools by plugin key and labels them by name', () => { + expect( + toToolOptions([ + { name: 'Calculator', pluginKey: 'calculator', description: 'Math' }, + { pluginKey: 'wolfram' }, + { name: 'No key' }, + { name: 'Dup', pluginKey: 'calculator' }, + ]), + ).toEqual([ + { value: 'calculator', label: 'Calculator', description: 'Math' }, + { value: 'wolfram', label: 'wolfram' }, + ]); + }); +}); + +describe('endpoint server functions', () => { + it('reads the enabled endpoints from /api/endpoints', async () => { + apiFetchMock.mockResolvedValue(json({ anthropic: { userProvide: false } })); + await expect(getEndpointStatusFn()).resolves.toEqual({ + anthropic: { name: 'anthropic', custom: false, userProvide: false }, + }); + expect(apiFetchMock).toHaveBeenCalledWith('/api/endpoints', undefined); + }); + + it('reads the models from /api/models and fails loudly on an error status', async () => { + apiFetchMock.mockResolvedValueOnce(json({ anthropic: ['a'] })); + await expect(getModelsFn()).resolves.toEqual({ anthropic: ['a'] }); + apiFetchMock.mockResolvedValueOnce(json({ message: 'nope' }, 500)); + await expect(getModelsFn()).rejects.toThrow('Failed to load models: 500'); + }); + + it("forwards the admin's user agent to the agent tools route", async () => { + userAgentMock.mockReturnValue('Mozilla/5.0'); + apiFetchMock.mockResolvedValue(json([{ name: 'Calculator', pluginKey: 'calculator' }])); + await expect(getToolsFn()).resolves.toEqual([{ value: 'calculator', label: 'Calculator' }]); + expect(apiFetchMock).toHaveBeenCalledWith('/api/agents/tools', { + headers: { 'User-Agent': 'Mozilla/5.0' }, + }); + }); +}); diff --git a/src/server/endpoints.ts b/src/server/endpoints.ts new file mode 100644 index 00000000..a2fe042c --- /dev/null +++ b/src/server/endpoints.ts @@ -0,0 +1,140 @@ +import { queryOptions } from '@tanstack/react-query'; +import { createServerFn } from '@tanstack/react-start'; +import { getRequestHeader } from '@tanstack/react-start/server'; +import type * as t from '@/types'; +import { apiFetch } from './utils/api'; + +/** One entry of LibreChat's `GET /api/endpoints` payload (only the keys the panel reads). */ +interface RawEndpointConfig { + type?: string; + userProvide?: boolean; + userProvideURL?: boolean; + modelDisplayLabel?: string; +} + +/** One entry of LibreChat's `GET /api/agents/tools` payload. */ +interface RawTool { + name?: string; + pluginKey?: string; + description?: string; +} + +function isRecord(value: t.ConfigValue): value is t.ConfigRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Enabled endpoints from `GET /api/endpoints` (as LibreChat serves them to the + * signed-in admin). Endpoints the server leaves out are not enabled: missing + * credentials, or not listed in `ENDPOINTS`. + */ +export function toEndpointStatusMap(payload: t.ConfigValue): t.EndpointStatusMap { + const result: t.EndpointStatusMap = {}; + if (!isRecord(payload)) return result; + for (const [name, raw] of Object.entries(payload)) { + if (!isRecord(raw)) continue; + const entry = raw as RawEndpointConfig; + result[name] = { + name, + custom: entry.type === 'custom', + userProvide: entry.userProvide === true || entry.userProvideURL === true, + ...(typeof entry.modelDisplayLabel === 'string' && { label: entry.modelDisplayLabel }), + }; + } + return result; +} + +/** Model names per endpoint from `GET /api/models`: blank and duplicate names dropped. */ +export function toEndpointModelsMap(payload: t.ConfigValue): t.EndpointModelsMap { + const result: t.EndpointModelsMap = {}; + if (!isRecord(payload)) return result; + for (const [endpoint, models] of Object.entries(payload)) { + if (!Array.isArray(models)) continue; + const names = new Set(); + for (const model of models) { + if (typeof model === 'string' && model.trim() !== '') names.add(model); + } + result[endpoint] = [...names]; + } + return result; +} + +/** Tool options from `GET /api/agents/tools`, labelled by name and valued by plugin key. */ +export function toToolOptions(payload: t.ConfigValue): t.ToolOption[] { + if (!Array.isArray(payload)) return []; + const options: t.ToolOption[] = []; + const seen = new Set(); + for (const item of payload) { + if (!isRecord(item)) continue; + const raw = item as RawTool; + const value = raw.pluginKey; + if (typeof value !== 'string' || value === '' || seen.has(value)) continue; + seen.add(value); + options.push({ + value, + label: typeof raw.name === 'string' && raw.name ? raw.name : value, + ...(typeof raw.description === 'string' && { description: raw.description }), + }); + } + return options; +} + +async function readJson(response: Response, what: string): Promise { + if (!response.ok) throw new Error(`Failed to load ${what}: ${response.status}`); + return (await response.json()) as t.ConfigValue; +} + +/** Which endpoints LibreChat serves (AP-5 status badges, `endpoints` option source). */ +export const getEndpointStatusFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const response = await apiFetch('/api/endpoints'); + return toEndpointStatusMap(await readJson(response, 'endpoints')); + }, +); + +/** The models each endpoint offers (model list defaults, `models:` option source). */ +export const getModelsFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const response = await apiFetch('/api/models'); + return toEndpointModelsMap(await readJson(response, 'models')); + }, +); + +/** + * The tools agents can use (`tools` option source). LibreChat's agent routes + * reject requests without a browser user agent, so the admin's is forwarded. + */ +export const getToolsFn = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const userAgent = getRequestHeader('user-agent'); + const response = await apiFetch( + '/api/agents/tools', + userAgent ? { headers: { 'User-Agent': userAgent } } : undefined, + ); + return toToolOptions(await readJson(response, 'tools')); + }, +); + +/** Refetched after saves: the key carries the base config's version (see `useOptionSources`). */ +export const endpointStatusOptions = (version = 0) => + queryOptions({ + queryKey: ['endpointStatus', version], + queryFn: () => getEndpointStatusFn(), + staleTime: 60_000, + retry: 1, + }); + +export const endpointModelsOptions = (version = 0) => + queryOptions({ + queryKey: ['endpointModels', version], + queryFn: () => getModelsFn(), + staleTime: 60_000, + retry: 1, + }); + +export const toolOptionsQuery = queryOptions({ + queryKey: ['agentTools'], + queryFn: () => getToolsFn(), + staleTime: 5 * 60_000, + retry: 1, +}); diff --git a/src/server/index.ts b/src/server/index.ts index ad8cf1d7..475377b0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ export * from './auth'; export * from './capabilities'; export * from './config'; +export * from './endpoints'; export * from './groups'; export * from './langfuse'; export * from './roles'; diff --git a/src/styles.css b/src/styles.css index 74ab646e..9ac3890b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1802,6 +1802,317 @@ body > div:has(+ .modal-frost) { } } +/* ── AI providers editor (WS9) ─────────────────────────────────── + Provider status badges, collapsed summaries, side-by-side pairs, the + read-only Azure groups table, and shadowed (Global) rows. */ + +.provider-status { + display: inline-flex; + align-items: center; + gap: 6px; + height: 1.375rem; + padding: 0 8px; + border: 1px solid var(--cui-color-stroke-default); + border-radius: 9999px; + font-size: var(--settings-fs-meta); + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: var(--cui-color-text-default); + vertical-align: middle; +} + +.provider-status-dot { + width: 8px; + height: 8px; + flex-shrink: 0; + border-radius: 9999px; + background: var(--settings-muted-fg); +} + +.provider-status[data-status='active'] .provider-status-dot, +.provider-status[data-status='userProvided'] .provider-status-dot { + background: var(--cui-color-accent-success); +} + +.provider-status[data-status='disabled'] { + border-style: dashed; + color: var(--settings-muted-fg); +} + +.provider-status[data-status='disabled'] .provider-status-dot { + background: transparent; + box-shadow: inset 0 0 0 1.5px var(--settings-muted-fg); +} + +.provider-status-row .settings-row-control { + justify-content: flex-start; + padding-top: 5px; +} + +/* A collapsed group's header names what it sets, on one line. */ +.provider-group-summary { + min-width: 0; + flex: 1; + overflow: hidden; + font-size: var(--settings-fs-help); + font-weight: 400; + color: var(--cui-color-text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-card-summary { + display: block; + margin-top: 2px; + overflow-wrap: anywhere; +} + +.provider-link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; +} + +/* Two related numbers side by side (recursion limits): each cell is its own + container, so its row stacks the label above the control. */ +.provider-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.provider-pair-cell { + min-width: 0; + container-type: inline-size; +} + +.settings-group-body > .provider-pair { + margin-inline: 0; + max-width: none; +} + +@media (max-width: 639.98px) { + .provider-pair { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Collapsed groups pack two per line on wide screens; an open group spans the card. */ +.provider-groups { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +@container (min-width: 720px) { + .provider-groups { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .provider-groups > .settings-group:has(> .settings-group-header [aria-expanded='true']) { + grid-column: 1 / -1; + } +} + +.settings-section-body > .provider-groups { + margin-inline: 0; + max-width: none; +} + +/* Capability grids take the full row width, label above, three columns. */ +.settings-row:has(> .settings-row-control > fieldset[id$='-capabilities']) { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'label trailing' + 'control control'; + row-gap: 8px; +} + +.settings-row:has(> .settings-row-control > fieldset[id$='-capabilities']) .settings-row-label { + padding-top: 0; +} + +.settings-row:has(> .settings-row-control > fieldset[id$='-capabilities']) .settings-row-control { + margin-left: var(--settings-indent, 0px); +} + +.settings-row-control > fieldset[id$='-capabilities'] { + max-width: none; + row-gap: 10px; +} + +@container (min-width: 720px) { + .settings-row-control > fieldset[id$='-capabilities'] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +/* A value Global overrides take precedence over (D11): the chip says why, + the label and control step back. */ +.settings-row:has(> .settings-row-trailing [data-state-chip='shadowed']) .settings-row-label, +.settings-row:has(> .settings-row-trailing [data-state-chip='shadowed']) .settings-row-control { + opacity: 0.62; +} + +.state-chip-link { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 2px; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +.state-chip-link:hover { + text-decoration: underline; +} + +.azure-groups { + container-type: inline-size; + overflow-x: auto; + border: 1px solid var(--cui-color-stroke-default); + border-radius: 8px; +} + +.azure-groups-table { + width: 100%; + border-collapse: collapse; + font-size: var(--settings-fs-help); + color: var(--cui-color-text-default); +} + +.azure-groups-table th, +.azure-groups-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--cui-color-stroke-default); + text-align: left; + vertical-align: middle; +} + +.azure-groups-table thead th { + font-size: var(--settings-fs-meta); + font-weight: 600; + color: var(--settings-muted-fg); + white-space: nowrap; + background: var(--cui-color-background-muted); +} + +.azure-groups-table tbody tr:last-child > * { + border-bottom: 0; +} + +.azure-groups-table tbody th { + font-weight: 600; +} + +.azure-group-name { + overflow-wrap: anywhere; +} + +.azure-target { + display: block; + max-width: 22rem; + overflow: hidden; + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-size: var(--settings-fs-meta); + text-overflow: ellipsis; + white-space: nowrap; + color: var(--settings-muted-fg); +} + +.azure-col-version { + white-space: nowrap; +} + +.azure-col-number { + text-align: right !important; + font-variant-numeric: tabular-nums; +} + +.azure-col-action { + width: 1%; + text-align: right !important; +} + +.azure-details-btn { + padding: 4px 10px; + border: 1px solid var(--cui-color-stroke-default); + border-radius: 6px; + background: var(--cui-color-background-panel); + color: var(--cui-color-text-default); + font: inherit; + font-size: var(--settings-fs-meta); + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.azure-details-btn:hover { + background: var(--cui-color-background-hover); +} + +.azure-muted { + color: var(--settings-muted-fg); +} + +@container (max-width: 639.98px) { + .azure-col-optional { + display: none; + } +} + +@container (max-width: 419.98px) { + .azure-col-wide { + display: none; + } +} + +.azure-models-table { + border: 1px solid var(--cui-color-stroke-default); + border-radius: 8px; + border-collapse: separate; + border-spacing: 0; +} + +.azure-drawer-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; +} + +.azure-drawer-item { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr); + gap: 12px; + font-size: var(--settings-fs-help); +} + +.azure-drawer-item dt { + color: var(--settings-muted-fg); +} + +.azure-drawer-item dd { + margin: 0; + min-width: 0; + color: var(--cui-color-text-default); +} + +@media (max-width: 479.98px) { + .azure-drawer-item { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } +} + /* ── Reduced motion (VS-5) ─────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { diff --git a/src/types/config.ts b/src/types/config.ts index 9b885b75..ad791378 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -258,6 +258,8 @@ export interface FieldMeta { defaultLabelKey?: string; /** Enum value → locale key of its display label (else `com_config_enum_` is tried). */ enumLabels?: Record; + /** Enum value → locale key of a one-line description (checkbox grids such as agent capabilities). */ + enumDescriptions?: Record; /** Enum values left out of selects unless currently set (aliases such as `d` for `day`). */ hiddenOptions?: string[]; /** Child keys in display order. */ diff --git a/src/types/controls.ts b/src/types/controls.ts index 6a8c9c91..8a47a9d2 100644 --- a/src/types/controls.ts +++ b/src/types/controls.ts @@ -159,6 +159,8 @@ export interface RefSelectFieldProps { id: string; value: string | undefined; source?: OptionSource; + /** Data path of the field: a model field suggests the models of the endpoint its sibling names (WS9). */ + path?: string; /** Extra options merged before the source's (e.g. `current_model`). */ extraOptions?: SelectOption[]; onChange: (value: string) => void; @@ -178,6 +180,12 @@ export interface ModelListFieldProps { value: ConfigValue; /** Models used while the list is unset (provider defaults), shown read-only. */ defaults?: string[]; + /** Data path of the list, for its saved state (whether `/api/models` shows its defaults). */ + path?: string; + /** `models:`: defaults and suggestions from `/api/models` when `defaults` is not given (WS9). */ + source?: OptionSource; + /** The list holds plain model names (`array`): no descriptions. */ + namesOnly?: boolean; onChange: (value: ConfigValue) => void; /** Return to "Use provider defaults"; called instead of saving `[]`. */ onClear: ClearHandler; diff --git a/src/types/fields.ts b/src/types/fields.ts index 81af8df8..db19ecad 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -9,6 +9,7 @@ import type { ControlWidth, ControlKind, FieldUnit, + OptionSource, } from './config'; /** @@ -210,6 +211,8 @@ export interface ListFieldProps { /** Items are whitespace-significant (stop sequences): a blank item is kept, only an empty one is dropped. */ keepsWhitespace?: boolean; width?: ControlWidth; + /** An item's problem shown inline as it is entered (e.g. "Add a port"); it is not added until fixed. */ + validateItem?: (value: string) => string | null; 'aria-label'?: string; } @@ -359,3 +362,8 @@ export interface ListRecordFieldProps { disabled?: boolean; 'aria-label'?: string; } + +/** A `ListField` whose items are picked from an option source (WS9). */ +export interface RefListFieldProps extends Omit { + source: OptionSource; +} diff --git a/src/types/index.ts b/src/types/index.ts index 096f7b40..5452a79c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ export type * from './grant'; export type * from './hooks'; export type * from './layout'; export type * from './review'; +export type * from './providers'; export type * from './role'; export type * from './scope'; export type * from './server'; diff --git a/src/types/providers.ts b/src/types/providers.ts new file mode 100644 index 00000000..87efec85 --- /dev/null +++ b/src/types/providers.ts @@ -0,0 +1,224 @@ +import type { ReactNode, RefObject } from 'react'; +import type { ConfigValue, FlatConfigMap, SchemaField, SelectOption } from './config'; +import type { ShadowedBy } from './values'; +import type { FieldRendererProps } from './config-ui'; + +/** One endpoint LibreChat serves to the signed-in admin (`GET /api/endpoints`). */ +export interface EndpointStatus { + /** Endpoint key: a built-in provider (`openAI`) or a custom endpoint name. */ + name: string; + /** Custom endpoints carry `type: 'custom'`; built-in providers have none. */ + custom: boolean; + /** Users enter their own API key (or URL) in the chat instead of the server's. */ + userProvide: boolean; + /** Display label LibreChat uses for a custom endpoint (`modelDisplayLabel`). */ + label?: string; +} + +/** The enabled endpoints, keyed by name; endpoints LibreChat leaves out are not enabled. */ +export type EndpointStatusMap = Record; + +/** Model names per endpoint (`GET /api/models`). */ +export type EndpointModelsMap = Record; + +/** A tool agents can use (`GET /api/agents/tools`), as a select option. */ +export interface ToolOption extends SelectOption { + description?: string; +} + +/** Runtime status of a provider: served by LibreChat, or left out. */ +export type ProviderStatusKind = 'active' | 'userProvided' | 'disabled' | 'unknown'; + +/** What a provider's status badge and credentials row show. */ +export interface ProviderStatusInfo { + kind: ProviderStatusKind; + /** Locale key naming where the provider's credentials come from. */ + credentialsKey?: string; +} + +/** A purpose group of a provider card (Title generation, Activity labels, …). */ +export interface ProviderGroupDef { + key: string; + labelKey: string; + descriptionKey?: string; + /** Child keys of the provider, in display order. */ + fields: string[]; + /** Opens by default (Agent features, Azure groups); others start collapsed. */ + expanded?: boolean; + /** Settings LibreChat ignores for this built-in provider: shown last, with an explanation. */ + unused?: boolean; + /** Pairs of fields shown side by side on wide screens (recursion limits). */ + pairs?: Array<[string, string]>; +} + +/** A purpose group resolved against the provider's schema fields. */ +export interface ProviderGroup { + def: ProviderGroupDef; + fields: SchemaField[]; +} + +/** A set value of a provider, for the one-line summaries of cards and groups. */ +export interface SummaryItem { + label: string; + value: string; +} + +export interface ProviderCardProps extends Omit { + field: SchemaField; +} + +export interface ProviderGroupSectionProps extends Omit< + FieldRendererProps, + 'fields' | 'parentPath' | 'parentValue' +> { + group: ProviderGroup; + /** The provider's data path (`endpoints.openAI`). */ + providerPath: string; + /** The provider's shown value (pending edits included). */ + providerValue: ConfigValue; + /** Storage id of the group's open state. */ + stateId: string; + /** A banner above the group's rows (Global title settings). */ + notice?: ReactNode; + /** Replaces the collapsed summary ("Ignored while Global overrides exist"). */ + summaryOverride?: string; + /** Shown before the customized count ("9 groups"). */ + countBadge?: ReactNode; + /** Extra content above the group's rows (the Azure groups table). */ + before?: ReactNode; + /** Shown after the rows (read-only unknown keys in Advanced). */ + after?: ReactNode; +} + +export interface ProviderStatusBadgeProps { + provider: string; +} + +export interface ProviderStatusRowProps { + provider: string; +} + +/** One Azure OpenAI group as the read-only table shows it. */ +export interface AzureGroupRow { + group: string; + apiKey?: string; + instanceName?: string; + baseURL?: string; + deploymentName?: string; + version?: string; + serverless: boolean; + assistants: boolean; + models: AzureGroupModel[]; + additionalHeaders?: Record; + addParams?: Record; + dropParams?: string[]; +} + +/** A model of an Azure group: its deployment and API version overrides. */ +export interface AzureGroupModel { + name: string; + deploymentName?: string; + version?: string; + assistants?: boolean; +} + +export interface AzureGroupsTableProps { + path: string; + value: ConfigValue; +} + +export interface AzureGroupDrawerProps { + group: AzureGroupRow | null; + onClose: () => void; + /** The Details button that opened it, focused again on close. */ + returnFocus?: RefObject; +} + +/** A credential shown without its secret: an env reference by name, a literal masked. */ +export type MaskedCredential = + | { kind: 'env'; name: string } + | { kind: 'masked' } + | { kind: 'none' }; + +export interface NetworkAccessCardProps extends Omit { + field: SchemaField; +} + +/** Why an `allowedAddresses` entry is rejected, as a locale key (first relevant issue only). */ +export type AddressIssue = 'empty' | 'url' | 'port' | 'public' | 'format'; + +/** A server query as the providers editor reads it. */ +export interface QueryState { + data?: T; + isLoading: boolean; + isError: boolean; +} + +export interface PersistentExpandedOptions { + /** Storage namespace, one per tab (`providers`). */ + storageKey: string; + /** Stable id of the collapsible within the namespace. */ + id: string; + defaultExpanded?: boolean; + /** Data paths it shows: it opens while the tab filter matches inside them. */ + revealPaths?: readonly string[]; +} + +export interface PersistentExpanded { + isExpanded: boolean; + hasEverExpanded: boolean; + /** Attach to the section element so deep links (`config:expand`) can open it. */ + sectionRef: RefObject; + toggle: () => void; +} + +/** What a model list shows from `/api/models` (AP-2). */ +export interface ModelListDefaults { + /** The provider's default models; absent while the saved configuration sets a list. */ + defaults?: string[]; + /** Model names offered while typing. */ + suggestions: string[]; +} + +/** A run of a group's rows: consecutive fields, or a pair shown side by side. */ +export interface FieldRun { + fields: SchemaField[]; + pair: boolean; +} + +export interface SetBadgeProps { + /** Settings with a value (librechat.yaml or an override). */ + count: number; + /** Admin overrides among them. */ + overrides?: number; +} + +export interface ShadowBadgeProps { + shadowedBy: ShadowedBy; + /** Where the overridden value comes from ("librechat.yaml"), named in the reason. */ + source?: string; +} + +/** How a summary reads a provider's values. */ +export interface SummaryReader { + /** The path holds a value (librechat.yaml, an override or a pending edit). */ + isSet: (path: string) => boolean; + valueAt: (path: string) => ConfigValue; + /** Global overrides take precedence over the value (D11). */ + isShadowed?: (path: string) => boolean; +} + +export interface SummaryReaderInput { + providerPath: string; + providerValue: ConfigValue; + getValue: (path: string, fallback: ConfigValue) => ConfigValue; + configuredPaths?: ReadonlySet; + editedValues?: FlatConfigMap; + root: ConfigValue; +} + +export interface StaleOverrideProps { + pendingRemoval: boolean; + /** Queues the override's removal; absent when the target cannot be edited. */ + onRemove?: () => void; +} From b35ef73ab7b56b3ff97cde64eff12795783fb99a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=94=8C=20feat:=20Custom=20Endpoints?= =?UTF-8?q?=20List=20and=20Editor=20Drawer=20(Wave=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom endpoints editor (Wave 2, WS10). --- e2e/config-shell.spec.ts | 12 +- e2e/config.spec.ts | 80 +- e2e/saves.spec.ts | 93 ++- src/components/configuration/ConfigPage.tsx | 73 +- .../configuration/meta/endpoints.ts | 12 + src/components/configuration/meta/index.ts | 7 +- .../configuration/renderer/InlineField.tsx | 71 +- .../sections/CreateCustomEndpointDialog.tsx | 123 --- .../sections/endpoints/Banner.tsx | 49 ++ .../sections/endpoints/Chips.tsx | 84 +++ .../sections/endpoints/Create.tsx | 317 ++++++++ .../endpoints/CustomEndpointsRenderer.tsx | 485 +++++++++--- .../sections/endpoints/Drawer.tsx | 92 +++ .../sections/endpoints/Editor.tsx | 478 ++++++++++++ .../sections/endpoints/Fields.tsx | 520 +++++++++++++ .../sections/endpoints/GroupedFields.tsx | 165 ---- .../configuration/sections/endpoints/List.tsx | 368 +++++++++ .../sections/endpoints/ParamDefinitions.tsx | 702 +++++++++++++++--- .../sections/endpoints/Pricing.tsx | 206 +++++ .../sections/endpoints/Request.tsx | 214 ++++++ .../CustomEndpointsRenderer.test.tsx | 250 ++++++- .../endpoints/__tests__/controls.test.tsx | 184 +++++ .../endpoints/__tests__/model.test.ts | 155 ++++ .../sections/endpoints/groups.ts | 261 ++++--- .../configuration/sections/endpoints/index.ts | 7 +- .../sections/endpoints/issues.ts | 21 + .../configuration/sections/endpoints/model.ts | 159 ++++ src/constants/endpoints.ts | 150 ++++ src/constants/index.ts | 1 + src/contexts/CollectionEditsContext.ts | 13 + src/contexts/EntryFieldChipsContext.ts | 9 + src/hooks/config/endpoints.ts | 131 ++++ src/hooks/config/index.ts | 1 + src/locales/en/translation.json | 199 +++++ src/types/endpoints.ts | 303 ++++++++ src/types/index.ts | 1 + src/utils/validation.ts | 3 + 37 files changed, 5224 insertions(+), 775 deletions(-) create mode 100644 src/components/configuration/meta/endpoints.ts delete mode 100644 src/components/configuration/sections/CreateCustomEndpointDialog.tsx create mode 100644 src/components/configuration/sections/endpoints/Banner.tsx create mode 100644 src/components/configuration/sections/endpoints/Chips.tsx create mode 100644 src/components/configuration/sections/endpoints/Create.tsx create mode 100644 src/components/configuration/sections/endpoints/Drawer.tsx create mode 100644 src/components/configuration/sections/endpoints/Editor.tsx create mode 100644 src/components/configuration/sections/endpoints/Fields.tsx delete mode 100644 src/components/configuration/sections/endpoints/GroupedFields.tsx create mode 100644 src/components/configuration/sections/endpoints/List.tsx create mode 100644 src/components/configuration/sections/endpoints/Pricing.tsx create mode 100644 src/components/configuration/sections/endpoints/Request.tsx create mode 100644 src/components/configuration/sections/endpoints/__tests__/controls.test.tsx create mode 100644 src/components/configuration/sections/endpoints/__tests__/model.test.ts create mode 100644 src/components/configuration/sections/endpoints/issues.ts create mode 100644 src/components/configuration/sections/endpoints/model.ts create mode 100644 src/constants/endpoints.ts create mode 100644 src/contexts/CollectionEditsContext.ts create mode 100644 src/contexts/EntryFieldChipsContext.ts create mode 100644 src/hooks/config/endpoints.ts create mode 100644 src/types/endpoints.ts diff --git a/e2e/config-shell.spec.ts b/e2e/config-shell.spec.ts index db80994f..d0a34149 100644 --- a/e2e/config-shell.spec.ts +++ b/e2e/config-shell.spec.ts @@ -145,17 +145,17 @@ test.describe('Configuration shell: search and deep links', () => { await expect(page).toHaveURL(/tab=system/); }); - test('Ctrl+K on a custom endpoint name opens its card', async ({ page }) => { + test('Ctrl+K on a custom endpoint name opens its editor', async ({ page }) => { await openTab(page, 'custom'); - const first = page.locator('form [data-entry-card] button[aria-expanded]').first(); - const name = (await first.locator('span[title]').first().getAttribute('title')) ?? ''; + const first = page.locator('form li[data-endpoint-row]').first(); + const name = (await first.getAttribute('data-endpoint-row')) ?? ''; test.skip(!name, 'No custom endpoints'); await openTab(page, 'system'); await search(page, name); - const card = page.locator('form [data-entry-card] button[aria-expanded="true"]', { - hasText: name, + const editor = page.getByRole('dialog'); + await expect(editor.getByRole('heading', { name, exact: true })).toBeVisible({ + timeout: 10_000, }); - await expect(card.first()).toBeVisible({ timeout: 10_000 }); }); test('a deep link to a nested field expands its ancestors', async ({ page }) => { diff --git a/e2e/config.spec.ts b/e2e/config.spec.ts index 25918dd2..e27f4e79 100644 --- a/e2e/config.spec.ts +++ b/e2e/config.spec.ts @@ -67,64 +67,56 @@ test.describe('Configuration: Enter never edits another field', () => { await page.getByRole('button', { name: 'Discard' }).click(); }); - test('Enter in a create dialog still submits that dialog', async ({ page }) => { + test('Enter in the create endpoint form still submits it', async ({ page }) => { await openConfigTab(page, 'custom'); - await page - .getByRole('button', { name: /Create endpoint/ }) - .first() - .click(); - const dialog = page.getByRole('dialog'); - await dialog.locator('#create-endpoint-name').fill('Enter Endpoint'); - await dialog.locator('#create-endpoint-apiKey').fill('key'); - const models = dialog.getByPlaceholder(/Add models/); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); + const drawer = page.getByRole('dialog'); + await drawer.getByRole('button', { name: /^Blank endpoint/ }).click(); + await drawer.locator('#endpoints-custom-9999-name').fill('Enter Endpoint'); + await drawer.locator('#endpoints-custom-9999-apiKey').fill('key'); + const models = drawer.getByPlaceholder(/Add models/); await models.fill('enter-model'); await models.press('Enter'); - await expect(dialog).toHaveCount(1); - const baseURL = dialog.locator('#create-endpoint-baseURL'); + await expect(drawer.getByRole('heading', { name: 'Create endpoint' })).toBeVisible(); + const baseURL = drawer.locator('#endpoints-custom-9999-baseURL'); await baseURL.fill('https://enter.example.com/v1'); await baseURL.press('Enter'); - await expect(dialog).toHaveCount(0); + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Enter Endpoint' }), + ).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click(); await expect(unsavedBar(page)).toHaveCount(1); await page.getByRole('button', { name: 'Discard' }).click(); }); }); -test.describe('Configuration: delete buttons', () => { - test('every delete button is announced as "Delete "', async ({ page }) => { +test.describe('Configuration: deleting a custom endpoint', () => { + test('a librechat.yaml endpoint offers no delete; an added one is deleted with Undo (D1, D9)', async ({ + page, + }) => { await openConfigTab(page, 'custom'); - const deleteButtons = page.locator('form button.icon-btn-danger'); - test.skip((await deleteButtons.count()) === 0, 'No custom endpoints to delete'); - await expect(page.locator('form button[aria-label="trash"]')).toHaveCount(0); - for (const label of await deleteButtons.evaluateAll((els) => - els.map((el) => el.getAttribute('aria-label') ?? ''), - )) { - expect(label).toMatch(/^Delete \S/); - } - const results = await new AxeBuilder({ page }) + const rows = page.locator('form li[data-endpoint-row]'); + await expect(rows.first()).toBeVisible(); + const names = await new AxeBuilder({ page }) .include('form') - .withRules(['button-name']) + .withRules(['button-name', 'list', 'listitem']) .analyze(); - expect(results.violations).toEqual([]); - }); + expect(names.violations).toEqual([]); + const yamlRow = rows.filter({ hasText: 'librechat.yaml' }).first(); + test.skip((await yamlRow.count()) === 0, 'No librechat.yaml endpoints'); + await yamlRow.locator('button[aria-haspopup="dialog"]').click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0); + await drawer.getByRole('button', { name: 'Done' }).click(); - test('keyboard Enter on a card trash button deletes the entry without toggling the card', async ({ - page, - }) => { - await openConfigTab(page, 'custom'); - const headers = page.locator('form [data-entry-card] button[aria-expanded]'); - const before = await headers.count(); - test.skip(before === 0, 'No custom endpoints to delete'); - const trashes = page.locator('form [data-entry-card] button[aria-label^="Delete "]'); - test.skip( - (await trashes.count()) === 0, - 'Every custom endpoint comes from librechat.yaml (D1)', - ); - const trash = trashes.first(); - await trash.focus(); - await page.keyboard.press('Enter'); - await expect(headers).toHaveCount(before - 1); - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false'); + const added = rows.filter({ hasText: 'Added in admin' }).first(); + test.skip((await added.count()) === 0, 'Every custom endpoint comes from librechat.yaml (D1)'); + const name = (await added.getAttribute('data-endpoint-row')) ?? ''; + await added.locator('button[aria-haspopup="dialog"]').click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete endpoint' }).click(); + await expect(page.getByText('Will be deleted when you save')).toBeVisible(); await expect(unsavedBar(page)).toHaveCount(1); - await page.getByRole('button', { name: 'Discard' }).click(); + await page.getByRole('button', { name: `Undo deleting ${name}` }).click(); + await expect(unsavedBar(page)).toHaveCount(0); }); }); diff --git a/e2e/saves.spec.ts b/e2e/saves.spec.ts index be74cfa6..210cfeb6 100644 --- a/e2e/saves.spec.ts +++ b/e2e/saves.spec.ts @@ -17,15 +17,19 @@ async function openCustomEndpoints(page: Page) { await page.waitForLoadState('networkidle'); } -/** The expanded card of the custom endpoint named `name`. */ +/** The editor drawer of the custom endpoint named `name`. */ async function card(page: Page, name: string): Promise { - const entry = page.locator('[id^="section-endpoints-custom-"]').filter({ - has: page.locator('span.truncate').getByText(name, { exact: true }), - }); - await expect(entry).toHaveCount(1); - const header = entry.locator('button[aria-expanded]').first(); - if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click(); - return entry; + const row = page.locator(`form li[data-endpoint-row="${name}"]`); + await expect(row).toHaveCount(1); + await row.locator('button[aria-haspopup="dialog"]').click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('heading', { name, exact: true })).toBeVisible(); + return drawer; +} + +async function closeEditor(page: Page) { + await page.getByRole('dialog').getByRole('button', { name: 'Done' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); } async function review(page: Page) { @@ -54,10 +58,13 @@ test.describe('Configuration saves: custom endpoints by identity', () => { page, }) => { await openCustomEndpoints(page); - const label = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label'); + const label = (await card(page, YAML_ENDPOINT)).getByRole('textbox', { + name: 'Model display label', + }); const original = await label.inputValue(); const edited = `E2E ${Date.now()}`; await setText(label, edited); + await closeEditor(page); const dialog = await review(page); await expect(dialog).toContainText('Save 1 change to the base configuration?'); await expect(dialog).toContainText(YAML_ENDPOINT); @@ -67,18 +74,20 @@ test.describe('Configuration saves: custom endpoints by identity', () => { await page.reload(); await page.waitForLoadState('networkidle'); - const reloaded = (await card(page, YAML_ENDPOINT)).getByLabel('Model display label'); + const reloaded = (await card(page, YAML_ENDPOINT)).getByRole('textbox', { + name: 'Model display label', + }); await expect(reloaded).toHaveValue(edited); await setText(reloaded, original); + await closeEditor(page); await review(page); await saveAndWait(page); }); test('a librechat.yaml endpoint cannot be removed or renamed (D1)', async ({ page }) => { await openCustomEndpoints(page); - await expect(page.locator(`button[aria-label="Delete ${YAML_ENDPOINT}"]`)).toHaveCount(0); - const yamlCard = await card(page, YAML_ENDPOINT); + await expect(yamlCard.getByRole('button', { name: 'Delete endpoint' })).toHaveCount(0); await expect(yamlCard.getByRole('textbox', { name: /^Name/ })).toHaveCount(0); await expect(yamlCard.locator('[data-static-value][id$="-name"]')).toHaveText(YAML_ENDPOINT); }); @@ -86,33 +95,71 @@ test.describe('Configuration saves: custom endpoints by identity', () => { test('an endpoint added in the panel can be renamed and deleted', async ({ page }) => { const name = `E2E Endpoint ${Date.now()}`; await openCustomEndpoints(page); - await page - .getByRole('button', { name: /Create endpoint/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); const create = page.getByRole('dialog'); - await create.locator('#create-endpoint-name').fill(name); - await create.locator('#create-endpoint-apiKey').fill('user_provided'); - await create.locator('#create-endpoint-baseURL').fill('https://e2e.example.com/v1'); + await create.getByRole('button', { name: /^Blank endpoint/ }).click(); + await create.locator('#endpoints-custom-9999-name').fill(name); + await create.locator('#endpoints-custom-9999-apiKey').fill('user_provided'); + await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1'); const model = create.getByLabel('Add models, comma-separated'); await model.fill('e2e-model'); await model.press('Enter'); - await create.getByRole('button', { name: /^Create$/ }).click(); + await create.getByRole('button', { name: 'Create endpoint', exact: true }).click(); + await closeEditor(page); await review(page); await saveAndWait(page); - await setText((await card(page, name)).getByLabel(/^Name\s*\*?$/), `${name} 2`); + const editor = await card(page, name); + await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`); + await closeEditor(page); const dialog = await review(page); await expect(dialog).toContainText(`${name} 2`); await saveAndWait(page); await page.reload(); await page.waitForLoadState('networkidle'); - await page.locator(`button[aria-label="Delete ${name} 2"]`).click(); + const renamed = await card(page, `${name} 2`); + await renamed.getByRole('button', { name: 'Delete endpoint' }).click(); await review(page); await saveAndWait(page); await page.reload(); await page.waitForLoadState('networkidle'); - await expect(page.locator(`button[aria-label="Delete ${name} 2"]`)).toHaveCount(0); + await expect(page.locator(`form li[data-endpoint-row="${name} 2"]`)).toHaveCount(0); + }); + + test('renaming an endpoint whose key is stored asks for the key again (CE-1)', async ({ + page, + }) => { + const name = `E2E Keyed ${Date.now()}`; + await openCustomEndpoints(page); + await page.getByRole('button', { name: 'Create endpoint' }).first().click(); + const create = page.getByRole('dialog'); + await create.getByRole('button', { name: /^Blank endpoint/ }).click(); + await create.locator('#endpoints-custom-9999-name').fill(name); + await create.locator('#endpoints-custom-9999-apiKey').fill('sk-e2e-literal-key-0000ABCD'); + await create.locator('#endpoints-custom-9999-baseURL').fill('https://e2e.example.com/v1'); + const model = create.getByLabel('Add models, comma-separated'); + await model.fill('e2e-model'); + await model.press('Enter'); + await create.getByRole('button', { name: 'Create endpoint', exact: true }).click(); + await closeEditor(page); + await review(page); + await saveAndWait(page); + + const editor = await card(page, name); + await setText(editor.getByLabel(/^Name\s*\*?$/), `${name} 2`); + await expect( + editor.getByRole('alert').filter({ hasText: /Enter the API key again/ }), + ).toBeVisible(); + await closeEditor(page); + const blocked = await review(page); + await expect(blocked.getByRole('button', { name: /^Save/ })).toBeDisabled(); + await blocked.getByRole('button', { name: 'Cancel' }).click(); + await page.getByRole('button', { name: 'Discard' }).click(); + + const cleanup = await card(page, name); + await cleanup.getByRole('button', { name: 'Delete endpoint' }).click(); + await review(page); + await saveAndWait(page); }); }); diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 743bc8b0..6a1f7bf6 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -37,6 +37,7 @@ import { getKeyedCollection, isConfigValueEqual, } from '@/utils'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; import { buildTocEntries, findTocEntry, toJumpTargets } from './toc'; import { RevealTargetContext } from '@/contexts/RevealTargetContext'; @@ -571,41 +572,43 @@ export function ConfigPage({ {isReady && ( - + + + )} diff --git a/src/components/configuration/meta/endpoints.ts b/src/components/configuration/meta/endpoints.ts new file mode 100644 index 00000000..6462320a --- /dev/null +++ b/src/components/configuration/meta/endpoints.ts @@ -0,0 +1,12 @@ +import type * as t from '@/types'; + +/** + * Field metadata of custom endpoints (WS10): the one value of `provider` + * read as what it does. Help text uses the derived + * `com_config_desc_endpoints_custom_*` keys. + */ +export const ENDPOINT_FIELD_META: t.FieldMetaRegistry = { + 'endpoints.custom[].provider': { + enumLabels: { anthropic: 'com_config_ce_provider_anthropic' }, + }, +}; diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index 8035ad11..21606291 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -2,10 +2,15 @@ import type * as t from '@/types'; import { buildFieldMetaRegistry, resolveFieldMeta } from './registry'; import { PROVIDER_FIELD_META } from './providers'; import { SECRET_CONFIG_KEYS } from '@/constants'; +import { ENDPOINT_FIELD_META } from './endpoints'; import { GENERIC_FIELD_META } from './generic'; /** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */ -const META_SOURCES: t.FieldMetaRegistry[] = [GENERIC_FIELD_META, PROVIDER_FIELD_META]; +const META_SOURCES: t.FieldMetaRegistry[] = [ + GENERIC_FIELD_META, + PROVIDER_FIELD_META, + ENDPOINT_FIELD_META, +]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); diff --git a/src/components/configuration/renderer/InlineField.tsx b/src/components/configuration/renderer/InlineField.tsx index e701e610..f7be3557 100644 --- a/src/components/configuration/renderer/InlineField.tsx +++ b/src/components/configuration/renderer/InlineField.tsx @@ -12,6 +12,7 @@ import { } from '@/utils'; import { lowerLayerKeys, lowerLayerValue, entryFieldDefault, layerRevertTarget } from './defaults'; import { ConfiguredOnlyContext } from '@/contexts/ConfiguredOnlyContext'; +import { EntryFieldChipsContext } from '@/contexts/EntryFieldChipsContext'; import { fieldMetaFor, fieldVariants, resolveControl } from './kinds'; import { RevealTargetContext } from '@/contexts/RevealTargetContext'; import { RecordObjectField } from '../fields/RecordObjectField'; @@ -462,7 +463,11 @@ function InlineFieldBody({ const label = labelForPath(dataPath, localize); const meta = fieldMetaFor(field, dataPath); const required = Boolean(showRequired) && !field.isOptional; - const { kind } = resolveControl({ field, path: dataPath, value: fieldValue }); + const { kind, width: controlWidth } = resolveControl({ + field, + path: dataPath, + value: fieldValue, + }); const state = useFieldState(dataPath); const { error } = state; const fieldDefault = entryFieldDefault(fieldValue, meta, state); @@ -493,6 +498,15 @@ function InlineFieldBody({ }; /** Keys of a record the layer beneath sets: their values can change, but they cannot be removed. */ const lockedKeys = lowerRecordKeys(state.isOverride ? state.revertTarget?.value : lower?.value); + const renderEntryChips = useContext(EntryFieldChipsContext); + const entryChips = renderEntryChips?.({ + path: dataPath, + label, + value: fieldValue, + onChange: (next) => onChange(field.key, next), + onRevert: clearField, + disabled, + }); /** Writes one key of this field's object value, merging into its latest value. */ const changeChild: EntryChange = (childPath, childValue) => { @@ -515,6 +529,7 @@ function InlineFieldBody({ required={required} description={fieldDescription(field, meta, localize)} error={error} + extraChips={entryChips} control={ disabled ? ( @@ -571,7 +586,7 @@ function InlineFieldBody({ if (kind === 'array-object') { const renderFn = collectionRenderOverrides?.[field.key] ?? renderCollectionEntryFields; return ( - + + + {extraChips} {shadowedBy && } - {required && ( - - )} ) : undefined; return ( @@ -736,6 +756,8 @@ function InlineRow({ control={control} chips={chips} error={error} + required={required} + width={width} /> ); } @@ -744,23 +766,36 @@ function CollectionRow({ title, fieldId, required, + extraChips, children, }: { title: string; fieldId: string; required?: boolean; + /** Chips a collection editor adds (`EntryFieldChipsContext`). */ + extraChips?: ReactNode; children: ReactNode; }) { + const heading = ( + + {title} + {required && ( + + )} + + ); return ( -
- - {title} - {required && ( - - )} - +
+ {extraChips ? ( +
+ {heading} + {extraChips} +
+ ) : ( + heading + )} {children}
); diff --git a/src/components/configuration/sections/CreateCustomEndpointDialog.tsx b/src/components/configuration/sections/CreateCustomEndpointDialog.tsx deleted file mode 100644 index a24d0f58..00000000 --- a/src/components/configuration/sections/CreateCustomEndpointDialog.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Dialog for creating a new custom endpoint entry. - * - * Uses the same grouped field layout as expanded custom endpoint cards — - * both are driven by the `FIELD_GROUPS` config in endpoints/groups.ts via - * the `renderFields` prop injected from the parent. - */ - -import { useState, useCallback, useMemo } from 'react'; -import { EModelEndpoint } from 'librechat-data-provider'; -import type * as t from '@/types'; -import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; -import { validateLoadableEndpoint } from '@/utils'; -import { FormDialog } from '@/components/shared'; -import { labelForPath } from '../meta'; -import { useLocalize } from '@/hooks'; - -/** The draft as it would be added: empty values and empty lists left out. */ -function toEntry(draft: t.ConfigRecord): t.ConfigRecord { - const entry: t.ConfigRecord = {}; - for (const [key, val] of Object.entries(draft)) { - if (val === '' || val === undefined || val === null) continue; - if (Array.isArray(val) && val.length === 0) continue; - entry[key] = val; - } - return entry; -} - -const DRAFT_PATH = 'create-endpoint'; - -const RESERVED_NAMES: ReadonlySet = new Set(Object.values(EModelEndpoint)); - -/** Why a name can't be used: another entry has it (in any letter case), or LibreChat reserves it. */ -function nameProblem(name: string, existingNames: ReadonlySet): string | null { - if (name === '') return null; - if (RESERVED_NAMES.has(name)) return 'com_config_endpoint_name_reserved'; - const lower = name.toLowerCase(); - for (const existing of existingNames) { - if (existing.toLowerCase() === lower) return 'com_config_endpoint_name_exists'; - } - return null; -} - -/** - * Create is enabled once the endpoint has what LibreChat needs to load it (a - * unique, non-reserved name, an API key, a base URL and a default model - * list); until then the footer says what is still missing. - */ -export function CreateCustomEndpointDialog({ - open, - onClose, - onSave, - fields, - existingNames, - renderFields, -}: { - open: boolean; - onClose: () => void; - onSave: (entry: t.ConfigRecord) => void; - fields: t.SchemaField[]; - existingNames: ReadonlySet; - renderFields: t.CollectionRenderFields; -}) { - const localize = useLocalize(); - const [draft, setDraft] = useState({}); - - const handleFieldChange = useCallback((key: string, value: t.ConfigValue) => { - setDraft((prev) => ({ ...prev, [key]: value })); - }, []); - - const name = typeof draft.name === 'string' ? draft.name.trim() : ''; - const problem = nameProblem(name, existingNames); - const isDuplicate = problem != null; - const missing = useMemo(() => { - const labels = validateLoadableEndpoint(toEntry(draft), 0).map((issue) => - labelForPath(`endpoints.custom.0.${issue.entryPath}`, localize), - ); - return name ? labels : [localize('com_config_field_name'), ...labels]; - }, [draft, name, localize]); - - const problemText = problem ? localize(problem) : undefined; - /** The name problem is also shown at the Name field, where it is fixed. */ - const draftErrors = useMemo((): t.PathErrors => { - const errors: t.PathErrors = {}; - if (problemText) errors[`${DRAFT_PATH}.name`] = problemText; - return errors; - }, [problemText]); - - const handleSubmit = useCallback(() => { - if (!name || isDuplicate || missing.length > 0) return; - onSave({ ...toEntry(draft), name }); - setDraft({}); - onClose(); - }, [draft, name, isDuplicate, missing, onSave, onClose]); - - const handleClose = useCallback(() => { - setDraft({}); - onClose(); - }, [onClose]); - - return ( - 0} - saving={false} - error={problemText} - hint={ - missing.length > 0 - ? localize('com_config_endpoint_required_missing', { fields: missing.join(', ') }) - : undefined - } - size="lg" - onSubmit={handleSubmit} - onClose={handleClose} - > - - {renderFields(fields, draft, DRAFT_PATH, handleFieldChange)} - - - ); -} diff --git a/src/components/configuration/sections/endpoints/Banner.tsx b/src/components/configuration/sections/endpoints/Banner.tsx new file mode 100644 index 00000000..a0619a00 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Banner.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { DecorativeIcon } from '@/components/shared'; +import { cn } from '@/utils'; + +const TONE_CLASSES: Record = { + info: 'border-(--settings-info-border) bg-(--settings-info-bg) text-(--settings-info-fg)', + warning: + 'border-(--settings-warn-mark) bg-(--settings-warn-bg) text-(--settings-warn-fg) border-dashed', + danger: 'border-(--settings-danger-mark) bg-(--settings-danger-bg) text-(--settings-danger-fg)', + neutral: + 'border-(--cui-color-stroke-default) bg-(--settings-neutral-bg) text-(--cui-color-text-default)', +}; + +const TONE_ICONS: Record = { + info: 'info-in-circle', + warning: 'warning', + danger: 'warning', + neutral: 'lock', +}; + +/** A short notice in the endpoint editor: where an endpoint comes from, a warning, an action. */ +export function Banner({ + tone, + children, + action, + role, +}: { + tone: t.EndpointBannerTone; + children: ReactNode; + action?: ReactNode; + role?: 'status' | 'alert'; +}) { + return ( +
+ + + +
{children}
+ {action &&
{action}
} +
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Chips.tsx b/src/components/configuration/sections/endpoints/Chips.tsx new file mode 100644 index 00000000..262b79ec --- /dev/null +++ b/src/components/configuration/sections/endpoints/Chips.tsx @@ -0,0 +1,84 @@ +import type * as t from '@/types'; +import { useLocalize, useFieldState, useValuesScope, useRevertLabels } from '@/hooks'; +import { layerRevertTarget, lowerLayerValue } from '../../renderer/defaults'; +import { DecorativeIcon, StateChip } from '@/components/shared'; +import { isConfigRecord, isConfigValueEqual } from '@/utils'; +import { sourceLabel } from '../../values'; + +/** Unset, empty text and empty lists or objects all read as "not set" when comparing with the saved value. */ +function normalize(value: t.ConfigValue): t.ConfigValue { + if (value === null || value === '') return undefined; + if (Array.isArray(value) && value.length === 0) return undefined; + if (isConfigRecord(value) && Object.keys(value).length === 0) return undefined; + return value; +} + +/** + * Chips of one endpoint field (WS10 CE-2): "Edited" with Undo while the field + * differs from its saved value; otherwise, for an endpoint the layer beneath + * defines, when the edit target overrides one of its values, "Admin + * override" (or the profile) with "Revert to librechat.yaml (…)" / "Remove + * override (inherits …)". Values the endpoint simply inherits, and endpoints + * the target added, carry no such chip: the drawer header names the source. + */ +export function FieldChips({ + path, + label, + value, + saved, + showOverride, + onChange, + onRevert, + disabled, +}: t.FieldChipsProps) { + const localize = useLocalize(); + const state = useFieldState(path); + const scope = useValuesScope(); + const revert = useRevertLabels(path, label); + if (disabled) return null; + const handleRevert = (): void => { + if (onRevert) { + onRevert(); + return; + } + const target = state.isOverride + ? layerRevertTarget(state.revertTarget) + : lowerLayerValue(state); + onChange(target?.value); + }; + + if (!isConfigValueEqual(normalize(value), normalize(saved))) { + return ( + onChange(saved)} + undoLabel={localize('com_config_ui_undo_change', { name: label })} + /> + ); + } + if (!showOverride || !state.isOverride) return null; + return ( + <> + + {revert && ( + + )} + + ); +} diff --git a/src/components/configuration/sections/endpoints/Create.tsx b/src/components/configuration/sections/endpoints/Create.tsx new file mode 100644 index 00000000..b7822d57 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Create.tsx @@ -0,0 +1,317 @@ +import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { BLANK_ENDPOINT_TEMPLATE, ENDPOINT_TEMPLATES } from '@/constants'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { isConfigRecord, isConfigValueEqual } from '@/utils'; +import { hasBlockingIssue, validateEndpoint } from './model'; +import { useLocalize, useValuesScope } from '@/hooks'; +import { sourceLabel } from '../../values'; +import { EndpointDrawer } from './Drawer'; +import { EndpointEditor } from './Editor'; +import { labelForPath } from '../../meta'; +import { describeIssue } from './issues'; +import { Banner } from './Banner'; + +/** + * Data path the draft is edited under: an index no list reaches, so labels, + * help and controls resolve like a real entry's (`endpoints.custom[]`) while + * the draft stays apart from the page's values. + */ +const DRAFT_PATH = 'endpoints.custom.9999'; + +/** Problems that only say something is still missing: listed in the footer, flagged at the field once Create is tried. */ +const MISSING_CODES: ReadonlySet = new Set([ + 'nameRequired', + 'apiKeyRequired', + 'baseUrlRequired', + 'modelsRequired', +]); + +/** The draft as it would be added: empty values and empty lists left out, the name trimmed. */ +function toEntry(draft: t.ConfigRecord): t.ConfigRecord { + const entry: t.ConfigRecord = {}; + for (const [key, value] of Object.entries(draft)) { + if (value === '' || value === undefined || value === null) continue; + if (Array.isArray(value) && value.length === 0) continue; + if (isConfigRecord(value) && Object.keys(value).length === 0) continue; + entry[key] = value; + } + if (typeof entry.name === 'string') entry.name = entry.name.trim(); + return entry; +} + +/** The template's name, or "Name 2", "Name 3"… when one is taken. */ +function freeName(name: string, taken: readonly string[]): string { + if (name === '') return name; + const lower = new Set(taken.map((other) => other.toLowerCase())); + if (!lower.has(name.toLowerCase())) return name; + let n = 2; + while (lower.has(`${name} ${n}`.toLowerCase())) n++; + return `${name} ${n}`; +} + +/** + * Creating a custom endpoint (WS10 CE-3, CE-10): pick a provider template or + * start blank, fill in what LibreChat needs to serve it (name, base URL, API + * key, at least one model), then Create adds it to the list as a pending + * change and opens it in the editor. Create stays disabled until the endpoint + * is complete; a filled-in draft asks before it is discarded. In a profile, a + * name Base already uses is redirected to overriding that endpoint (CE-8). + */ +export function EndpointCreator({ + open, + fields, + names, + inheritedNames, + onCreate, + onOverride, + onClose, + returnFocus, +}: t.EndpointCreatorProps) { + const localize = useLocalize(); + const scope = useValuesScope(); + const [template, setTemplate] = useState(null); + const [draft, setDraft] = useState({}); + const [attempted, setAttempted] = useState(false); + const [confirming, setConfirming] = useState(false); + + const reset = () => { + setTemplate(null); + setDraft({}); + setAttempted(false); + setConfirming(false); + }; + const close = () => { + reset(); + onClose(); + }; + + const start = (choice: t.EndpointTemplate) => { + const entry = { ...choice.entry }; + if (typeof entry.name === 'string') entry.name = freeName(entry.name, names); + setTemplate(choice); + setDraft(entry); + }; + + const entry = useMemo(() => toEntry(draft), [draft]); + const issues = useMemo( + () => + validateEndpoint({ + entry: draft, + otherNames: names, + fromLowerLayer: false, + storedSecret: false, + }), + [draft, names], + ); + const shownIssues = attempted ? issues : issues.filter((issue) => !MISSING_CODES.has(issue.code)); + const missing = issues + .filter((issue) => MISSING_CODES.has(issue.code)) + .map((issue) => labelForPath(`${DRAFT_PATH}.${issue.field}`, localize)); + const name = typeof entry.name === 'string' ? entry.name : ''; + const inherited = scope != null && inheritedNames.includes(name) ? name : null; + const blocked = hasBlockingIssue(issues) || inherited != null; + const isDirty = template != null && !isConfigValueEqual(draft, template.entry); + + const requestClose = () => { + if (isDirty && !confirming) { + setConfirming(true); + return; + } + close(); + }; + + const submit = () => { + if (blocked) { + setAttempted(true); + return; + } + onCreate(entry); + reset(); + }; + + const handleFieldChange = (key: string, value: t.ConfigValue) => { + setConfirming(false); + setDraft((prev) => { + const next = { ...prev, [key]: value }; + if (value === undefined) delete next[key]; + return next; + }); + }; + + const footer = confirming ? ( +
+ + {localize('com_config_ce_discard_confirm')} + +
+
+
+ ) : ( +
+ + {template && missing.length > 0 + ? localize('com_config_endpoint_required_missing', { fields: missing.join(', ') }) + : null} + +
+ {template && ( +
+
+ ); + + return ( + { + reset(); + onOverride(inherited); + }} + /> + } + > + {localize('com_config_ce_create_inherited', { + name: inherited, + scope: scope ? sourceLabel('scope', localize, scope) : '', + })} + + ) : undefined + } + footer={footer} + > + {template ? ( +
{ + event.preventDefault(); + submit(); + }} + className="flex flex-col gap-4" + > + + + +

+ {localize('com_config_ce_create_more')} +

+ {attempted && blocked && ( +

+ {issues + .filter((issue) => issue.severity === 'error') + .map((issue) => describeIssue(issue, localize)) + .join(' ')} +

+ )} +
+ + ))} + +
+ ); +} diff --git a/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx b/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx index 9b38b087..590d5daa 100644 --- a/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx +++ b/src/components/configuration/sections/endpoints/CustomEndpointsRenderer.tsx @@ -1,146 +1,395 @@ -import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import type * as t from '@/types'; -import { CreateCustomEndpointDialog } from '../CreateCustomEndpointDialog'; -import { ArrayObjectField } from '../../fields/ArrayObjectField'; -import { renderParamDefinitionFields } from './ParamDefinitions'; -import { useLocalize, useLowerEntryKeys } from '@/hooks'; -import { GroupedFieldRenderer } from './GroupedFields'; -import { DecorativeIcon } from '@/components/shared'; -import { isConfigRecord } from '@/utils'; - -/** Collection render overrides for custom endpoint nested fields. */ -const COLLECTION_RENDER_OVERRIDES: Record = { - paramDefinitions: renderParamDefinitionFields, -}; +import { EndpointList, SourceBadge, StatusBadge, Badge, isDeletable } from './List'; +import { useLocalize, useValuesScope, useEndpointRows } from '@/hooks'; +import { RevealTargetContext } from '@/contexts/RevealTargetContext'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { isConfigRecord, isRowVisible } from '@/utils'; +import { SearchInput } from '@/components/shared'; +import { revealConfigPath } from '../../reveal'; +import { sourceLabel } from '../../values'; +import { EndpointCreator } from './Create'; +import { EndpointDrawer } from './Drawer'; +import { EndpointEditor } from './Editor'; +import { modelNames } from './model'; +import { Banner } from './Banner'; -/** - * Custom `renderFields` function for custom endpoint entries. - * Renders fields in semantic groups instead of a flat list. - * Matches the `CollectionRenderFields` signature so it can be injected - * into `ArrayObjectField` and `ObjectEntryCard`. - */ -const LOCKED_NAME: ReadonlySet = new Set(['name']); +/** Shown once the list is long enough that scanning it is slower than typing. */ +const FILTER_THRESHOLD = 6; -/** - * `lockedNames`: endpoints the layer beneath defines (D1), whose name is - * shown read-only; a name two entries share is left editable so the - * duplicate can be fixed. - */ -function makeGroupedEndpointFields( - disabled: boolean | undefined, - lockedNames: ReadonlySet, -): t.CollectionRenderFields { - return (fields, parentValue, parentPath, onChange, _addFieldTriggerRef, editSessionId) => ( - - ); +function matchesFilter(row: t.EndpointRow, query: string): boolean { + if (query === '') return true; + const haystack = [row.title, row.host ?? '', ...modelNames(row.value)].join('\n').toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((word) => haystack.includes(word)); } -const NAME_SEPARATOR = '\u0000'; - -/** A draft in the create dialog is a new entry: nothing in it is locked, so a taken name can be corrected. */ -const NO_LOCKED_NAMES: ReadonlySet = new Set(); - -/** Names the layer beneath defines that exactly one current entry carries, joined (a stable memo key). */ -function uniqueLowerNames(items: t.ConfigValue[], lowerNames: ReadonlySet): string { - const counts = new Map(); - for (const item of items) { - if (!isConfigRecord(item) || typeof item.name !== 'string') continue; - counts.set(item.name, (counts.get(item.name) ?? 0) + 1); +/** The first field inside an entry the tab filter matched (`baseURL`), to open the editor at. */ +function filterMatchIn(filter: t.PathIndex | null, entryPath: string): string | undefined { + if (!filter) return undefined; + const prefix = `${entryPath}.`; + for (const path of filter.paths) { + if (path.startsWith(prefix)) return path.slice(prefix.length); } - return [...lowerNames].filter((name) => counts.get(name) === 1).join(NAME_SEPARATOR); + return undefined; } +/** `endpoints.custom.3.baseURL` → index 3 and field `baseURL`, for a path inside the list. */ +function locateEntry(path: string, listPath: string): { index: number; field?: string } | null { + if (!path.startsWith(`${listPath}.`)) return null; + const [segment, ...rest] = path.slice(listPath.length + 1).split('.'); + if (!/^\d+$/.test(segment)) return null; + return { index: Number(segment), field: rest.length > 0 ? rest.join('.') : undefined }; +} + +/** + * Custom endpoints tab (WS10): a compact, filterable list of the endpoints + * with where each comes from and whether it is served, and an editor drawer + * per endpoint. Edits are staged on the page like every other setting and + * saved per endpoint by name. Endpoints librechat.yaml (or, in a profile, + * Base) defines can be overridden but not renamed or deleted (D1); in a + * profile they open read-only until "Override for " (CE-8). + */ export function CustomEndpointsRenderer(props: t.FieldRendererProps) { const { fields, parentPath, parentValue, getValue, onChange, disabled, editSessionId } = props; const localize = useLocalize(); - const [createOpen, setCreateOpen] = useState(false); - const lowerNames = useLowerEntryKeys(`${parentPath}.custom`, 'name'); - const customField = fields.find((f) => f.key === 'custom'); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const revealTarget = useContext(RevealTargetContext); const path = `${parentPath}.custom`; - const parentObj = - parentValue && typeof parentValue === 'object' && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - const value = getValue(path, parentObj.custom ?? []); - const items = Array.isArray(value) ? value : []; - const lockedNames = uniqueLowerNames(items, lowerNames); - const renderGroupedEndpointFields = useMemo( - () => - makeGroupedEndpointFields( - disabled, - lockedNames ? new Set(lockedNames.split(NAME_SEPARATOR)) : NO_LOCKED_NAMES, - ), - [disabled, lockedNames], - ); - const renderDraftFields = useMemo( - () => makeGroupedEndpointFields(disabled, NO_LOCKED_NAMES), - [disabled], + const customField = fields.find((field) => field.key === 'custom'); + const parentObj = isConfigRecord(parentValue) ? parentValue : {}; + const listValue = getValue(path, parentObj.custom ?? []); + const items = useMemo(() => (Array.isArray(listValue) ? listValue : []), [listValue]); + const { rows, removals, lowerEntry } = useEndpointRows({ path, items }); + const [query, setQuery] = useState(''); + const [openKey, setOpenKey] = useState(null); + const [creating, setCreating] = useState(false); + const [overriding, setOverriding] = useState>(() => new Set()); + const [revealField, setRevealField] = useState(); + const [openAfterCreate, setOpenAfterCreate] = useState(null); + const bodyRef = useRef(null); + const latestRef = useRef({}); + const lastRowIdRef = useRef(''); + const createButtonRef = useRef(null); + const idPrefix = `section-${parentPath}-custom`; + const scopeName = scope ? sourceLabel('scope', localize, scope) : ''; + + const names = useMemo(() => rows.flatMap((row) => (row.identity ? [row.identity] : [])), [rows]); + const modelsOf = useMemo(() => { + const byName = new Map(rows.map((row) => [row.identity ?? '', modelNames(row.value)])); + return (endpoint: string): readonly string[] => byName.get(endpoint) ?? []; + }, [rows]); + const inheritedNames = useMemo( + () => rows.filter((row) => row.origin === 'inherited').map((row) => row.loadedName ?? ''), + [rows], ); + useEffect(() => { + setOverriding(new Set()); + }, [editSessionId]); + + const rowsRef = useRef(rows); + rowsRef.current = rows; + useEffect(() => { + if (!revealTarget) return; + const located = locateEntry(revealTarget, path); + const row = located ? rowsRef.current[located.index] : undefined; + if (!located || !row) return; + setCreating(false); + setOpenKey(row.key); + setRevealField(located.field); + }, [revealTarget, path]); + + useEffect(() => { + if (!openAfterCreate) return; + const row = rows.find((candidate) => candidate.isNew && candidate.identity === openAfterCreate); + if (!row) return; + setOpenAfterCreate(null); + setOpenKey(row.key); + }, [openAfterCreate, rows]); + if (!customField) return null; + const entryFields = customField.children ?? []; + const open = openKey ? rows.find((row) => row.key === openKey) : undefined; + if (open) { + latestRef.current = open.value; + lastRowIdRef.current = `${idPrefix}-${open.index}`; + } + const rowButton = (): HTMLElement | null => + document.querySelector( + `#${CSS.escape(lastRowIdRef.current)} button[aria-haspopup="dialog"]`, + ); + + const configuredOnly = Boolean(props.showConfiguredOnly) && scope != null; + const trimmedQuery = query.trim(); + const visible = rows.filter( + (row) => + isRowVisible(rowFilter, `${path}.${row.index}`) && + matchesFilter(row, trimmedQuery) && + (!configuredOnly || row.overridden || row.origin === 'scope'), + ); + const shownRemovals = trimmedQuery === '' ? removals : []; + + const closeDrawer = () => { + setOpenKey(null); + setRevealField(undefined); + }; + + const handleFieldChange = (key: string, value: t.ConfigValue) => { + if (!open) return; + const next = { ...latestRef.current, [key]: value }; + if (value === undefined) delete next[key]; + latestRef.current = next; + onChange(`${path}.${open.index}`, next); + }; + + const handleDelete = (row: t.EndpointRow) => { + if (!isDeletable(row)) return; + if (row.key === openKey) closeDrawer(); + onChange( + path, + items.filter((_, index) => index !== row.index), + ); + }; + + /** Back to what the layer beneath defines: the target's override of the endpoint is dropped on save. */ + const handleRevert = (row: t.EndpointRow) => { + const lower = row.loadedName ? lowerEntry(row.loadedName) : undefined; + if (!lower) return; + onChange(`${path}.${row.index}`, lower); + }; + + const handleUndoRemoval = (removal: t.EndpointRemoval) => { + const at = Math.min(removal.index, items.length); + onChange(path, [...items.slice(0, at), removal.value, ...items.slice(at)]); + }; const handleCreate = (entry: t.ConfigRecord) => { onChange(path, [...items, entry]); + setCreating(false); + setOpenAfterCreate(typeof entry.name === 'string' ? entry.name : null); + }; + + const handleOverrideInstead = (name: string) => { + setCreating(false); + const row = rows.find((candidate) => candidate.loadedName === name); + if (!row) return; + setOverriding((prev) => new Set(prev).add(row.key)); + setOpenKey(row.key); }; - const existingNames = new Set( - items.flatMap((item) => - isConfigRecord(item) && typeof item.name === 'string' ? [item.name.trim()] : [], - ), + + const readOnlyInherited = + open != null && open.origin === 'inherited' && !open.overridden && !overriding.has(open.key); + const editable = open != null && !disabled && !readOnlyInherited; + + const drawerNotice = (row: t.EndpointRow) => { + if (row.origin === 'yaml') { + return ( + handleRevert(row)} + /> + ) : undefined + } + > + {localize( + row.overridden ? 'com_config_ce_notice_yaml_overridden' : 'com_config_ce_notice_yaml', + )} + + ); + } + if (row.origin !== 'inherited') return undefined; + if (readOnlyInherited) { + return ( + setOverriding((prev) => new Set(prev).add(row.key))} + /> + ) + } + > + {localize('com_config_ce_notice_inherited', { scope: scopeName })} + + ); + } + return ( + handleRevert(row)} + /> + ) : undefined + } + > + {localize('com_config_ce_notice_inherited_override', { scope: scopeName })} + + ); + }; + + const drawerFooter = (row: t.EndpointRow) => ( + <> + {!disabled && isDeletable(row) && ( + - - )} - {disabled && isEmpty ? ( -
- {localize('com_config_no_custom_endpoints')} -
+
+
+ {rows.length >= FILTER_THRESHOLD && ( + + )} + {!disabled && ( +
+
+ )} +
+ {isEmpty ? ( +

+ {localize(scope ? 'com_config_ce_empty_scope' : 'com_config_no_custom_endpoints')} +

) : ( - onChange(path, v)} - onEntryChange={(index, v) => onChange(`${path}.${index}`, v)} - disabled={disabled} - hideAddButton - renderFields={renderGroupedEndpointFields} - entryIdPrefix={`section-${path.split('.')[0]}-custom`} - isEntryLocked={(item) => isConfigRecord(item) && lowerNames.has(String(item.name))} - editSessionId={editSessionId} - /> + <> + { + setRevealField(filterMatchIn(rowFilter, `${path}.${row.index}`)); + setOpenKey(row.key); + }} + onReveal={(row) => { + setCreating(false); + setOpenKey(row.key); + }} + onDelete={handleDelete} + onRevert={handleRevert} + onUndoRemoval={handleUndoRemoval} + idPrefix={idPrefix} + /> + {visible.length === 0 && ( +

+ {configuredOnly && trimmedQuery === '' + ? localize('com_config_ce_none_set_in_scope', { scope: scopeName }) + : localize('com_config_ce_filter_empty', { query: trimmedQuery })} +

+ )} + )} - setCreateOpen(false)} - onSave={handleCreate} - fields={customField.children ?? []} - existingNames={existingNames} - renderFields={renderDraftFields} + + + {open.isNew && ( + + )} + + + ) : undefined + } + subtitle={open?.host ?? undefined} + notice={open ? drawerNotice(open) : undefined} + footer={open ? drawerFooter(open) : null} + bodyRef={bodyRef} + returnFocus={rowButton} + > + {open && ( + { + const body = bodyRef.current; + if (body) revealConfigPath(body, { path: fieldPath }, { align: 'center' }); + }} + editSessionId={editSessionId} + /> + )} + + setCreating(false)} + returnFocus={() => createButtonRef.current?.querySelector('button') ?? null} />
); diff --git a/src/components/configuration/sections/endpoints/Drawer.tsx b/src/components/configuration/sections/endpoints/Drawer.tsx new file mode 100644 index 00000000..dec2e6ee --- /dev/null +++ b/src/components/configuration/sections/endpoints/Drawer.tsx @@ -0,0 +1,92 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import type * as t from '@/types'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { CloseButton } from '@/components/shared'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +/** + * Side drawer the custom endpoint editor opens in (WS10 CE-7), so the list + * stays in view behind it. Edits are staged on the page as they are made; + * closing never discards them. While creating, a close request goes to + * `onRequestClose` so a filled-in draft can be confirmed first. Closing + * returns focus to `returnFocus()` (the endpoint's row, or Create). + */ +export function EndpointDrawer({ + open, + onClose, + onRequestClose, + title, + badges, + subtitle, + notice, + footer, + children, + bodyRef, + returnFocus, +}: t.EndpointDrawerProps) { + const localize = useLocalize(); + const requestClose = onRequestClose ?? onClose; + + return ( + { + if (!isOpen) requestClose(); + }} + > + + + { + const target = returnFocus?.(); + if (!target) return; + event.preventDefault(); + target.focus(); + }} + className={cn( + 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl', + 'border-l border-(--cui-color-stroke-default) sm:w-[min(56rem,calc(100vw-3rem))]', + 'will-change-transform', + 'data-[state=closed]:animate-drawer-out data-[state=open]:animate-drawer-in', + )} + > +
+
+
+ + {title} + + {badges} +
+ +
+ {subtitle ?? localize('com_config_ce_drawer_desc')} +
+
+
+ +
+ {notice &&
{notice}
} + +
+ {children} +
+
+
+ {footer} +
+
+
+
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Editor.tsx b/src/components/configuration/sections/endpoints/Editor.tsx new file mode 100644 index 00000000..c584c912 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Editor.tsx @@ -0,0 +1,478 @@ +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + ENDPOINT_SECTIONS, + PLACED_TOP_KEYS, + sectionOfField, + HIDDEN_ENDPOINT_KEYS, + MODEL_REFERENCE_KEYS, + ESSENTIAL_SECTION_IDS, + ENDPOINT_REFERENCE_KEYS, +} from './groups'; +import { + NameRow, + ParamSetRow, + ModelRefRow, + HiddenKeyRow, + DropParamsRow, + EndpointRefRow, +} from './Fields'; +import { ConfigErrorsProvider, ConfigErrorsContext } from '@/contexts/ConfigErrorsContext'; +import { EntryFieldChipsContext } from '@/contexts/EntryFieldChipsContext'; +import { getEntryKey, getValueAtPath, isConfigRecord } from '@/utils'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { ParamDefinitionsRow } from './ParamDefinitions'; +import { UnknownSettings } from '../../UnknownSettings'; +import { HeaderPlaceholders } from './Request'; +import { SelectField } from '../../fields'; +import { labelForPath } from '../../meta'; +import { describeIssue } from './issues'; +import { PricingRow } from './Pricing'; +import { useLocalize } from '@/hooks'; +import { FieldChips } from './Chips'; +import { modelNames } from './model'; +import { Banner } from './Banner'; + +/** Fields an endpoint added in the panel can't be served without (CE-3). */ +const REQUIRED_KEYS: ReadonlySet = new Set(['name', 'apiKey', 'baseURL', 'models.default']); + +/** Required fields whose control says itself that it is empty (the model list). */ +const SELF_REPORTED: ReadonlySet = new Set(['modelsRequired']); + +/** For each model picker, the endpoint settings that say whose models it names. */ +const MODEL_ENDPOINT_KEYS: Readonly> = { + titleModel: ['titleEndpoint'], + activityModel: ['activityEndpoint', 'titleEndpoint'], + activityPhaseModel: ['activityPhaseEndpoint', 'activityEndpoint', 'titleEndpoint'], + reasoningLabelModel: ['reasoningLabelEndpoint', 'activityEndpoint', 'titleEndpoint'], +}; + +function asRecord(value: t.ConfigValue): t.ConfigRecord { + return isConfigRecord(value) ? value : {}; +} + +function hasValue(value: t.ConfigValue): boolean { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + if (isConfigRecord(value)) return Object.keys(value).length > 0; + return true; +} + +interface ResolvedField { + key: string; + field: t.SchemaField; + parentKey?: string; +} + +interface SectionLayout { + section: t.EndpointSectionDef; + visible: t.EndpointFieldDef[]; + hidden: t.EndpointFieldDef[]; + warnings: string[]; +} + +/** Schema fields by entry key, nested ones as `models.default`. */ +function indexFields(fields: t.SchemaField[]): Map { + const index = new Map(); + for (const field of fields) { + index.set(field.key, { key: field.key, field }); + const isGroup = field.children?.length && !field.isArray && field.type !== 'record'; + if (!isGroup) continue; + for (const child of field.children ?? []) { + const key = `${field.key}.${child.key}`; + index.set(key, { key, field: child, parentKey: field.key }); + } + } + return index; +} + +/** Schema keys no section places (newer LibreChat settings), for "Other settings". */ +function unplacedKeys(index: Map): t.EndpointFieldDef[] { + const placed = new Set(ENDPOINT_SECTIONS.flatMap((section) => section.fields.map((f) => f.key))); + const parents = new Set([...index.values()].flatMap((entry) => entry.parentKey ?? [])); + const defs: t.EndpointFieldDef[] = []; + for (const [key, resolved] of index) { + if (placed.has(key) || HIDDEN_ENDPOINT_KEYS.has(key) || parents.has(key)) continue; + const top = resolved.parentKey ?? key; + if (resolved.parentKey === undefined && PLACED_TOP_KEYS.has(key)) continue; + if (resolved.parentKey !== undefined && !PLACED_TOP_KEYS.has(top)) continue; + defs.push({ key, optional: true }); + } + return defs; +} + +/** The endpoint a model setting names models of: the first endpoint setting that is set, else this one. */ +function modelEndpoint(key: string, entry: t.ConfigRecord, own: string): string { + for (const endpointKey of MODEL_ENDPOINT_KEYS[key] ?? []) { + const named = entry[endpointKey]; + if (typeof named === 'string' && named !== '') return named; + } + return own; +} + +/** Keys of the parameter definitions an endpoint defines. */ +function definitionKeys(entry: t.ConfigRecord | undefined): ReadonlySet { + const list = asRecord(entry?.customParams).paramDefinitions; + if (!Array.isArray(list)) return new Set(); + return new Set(list.flatMap((item) => getEntryKey(item, 'key') ?? [])); +} + +/** The parameter set users of the endpoint get: `defaultParamsEndpoint`, else its native provider's. */ +function paramSetOf(entry: t.ConfigRecord): string { + const chosen = asRecord(entry.customParams).defaultParamsEndpoint; + if (typeof chosen === 'string' && chosen !== '' && chosen !== 'custom') return chosen; + return typeof entry.provider === 'string' ? entry.provider : 'custom'; +} + +/** + * The custom endpoint editor (WS10 CE-7): Connection, Models, Request + * shaping, Behaviour, Labels & reasoning, Parameters and Token pricing. + * Optional settings show only when set or added from "Add setting". Problems + * show at their field (errors) or atop their section (warnings), and every + * value the target overrides offers its revert (CE-2). + */ +export function EndpointEditor({ + path, + fields, + value, + onFieldChange, + issues, + origin, + disabled, + endpointNames, + storedSecret, + lowerEntry, + savedEntry, + modelsOf, + essentialsOnly, + showChips, + revealField, + onRevealed, + editSessionId, +}: t.EndpointEditorProps) { + const localize = useLocalize(); + const pageErrors = useContext(ConfigErrorsContext); + const index = useMemo(() => indexFields(fields), [fields]); + const latestRef = useRef(value); + latestRef.current = value; + const onRevealedRef = useRef(onRevealed); + onRevealedRef.current = onRevealed; + const [shown, setShown] = useState>(() => new Set()); + const [focusKey, setFocusKey] = useState(null); + const fromLower = origin === 'yaml' || origin === 'inherited'; + const name = typeof value.name === 'string' ? value.name : ''; + + /** Inline errors: this editor's own wording first, the save pipeline's for anything else. */ + const errors = useMemo((): t.PathErrors => { + const merged: t.PathErrors = { ...pageErrors }; + const own = new Set(); + for (const issue of issues) { + if (issue.severity !== 'error') continue; + const issuePath = `${path}.${issue.field}`; + if (!fromLower && SELF_REPORTED.has(issue.code)) { + delete merged[issuePath]; + continue; + } + if (own.has(issuePath)) continue; + own.add(issuePath); + merged[issuePath] = describeIssue(issue, localize); + } + return merged; + }, [issues, path, localize, pageErrors, fromLower]); + + const renderChips = useMemo((): t.EntryFieldChipsRenderer | null => { + if (!showChips || disabled) return null; + const prefix = `${path}.`; + return (props) => ( + + ); + }, [showChips, disabled, path, savedEntry, fromLower]); + + useEffect(() => { + if (!revealField) return; + const key = [...index.keys()].find( + (candidate) => revealField === candidate || revealField.startsWith(`${candidate}.`), + ); + if (key) setShown((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + const frame = requestAnimationFrame(() => onRevealedRef.current?.(`${path}.${revealField}`)); + return () => cancelAnimationFrame(frame); + }, [revealField, index, path]); + + useEffect(() => { + if (!focusKey) return; + setFocusKey(null); + requestAnimationFrame(() => { + const target = document.getElementById(toFieldId(`${path}.${focusKey}`)); + const control = target?.matches('input, select, textarea, button') + ? target + : target?.querySelector('input, select, textarea, button'); + control?.scrollIntoView?.({ block: 'center', behavior: 'smooth' }); + control?.focus({ preventScroll: true }); + }); + }, [focusKey, path]); + + const nestedChange = + (parentKey: string) => + (childPath: string, childValue: t.ConfigValue): void => { + const leaf = childPath.split('.').pop() ?? childPath; + const next = { ...asRecord(latestRef.current[parentKey]), [leaf]: childValue }; + if (childValue === undefined) delete next[leaf]; + latestRef.current = { ...latestRef.current, [parentKey]: next }; + onFieldChange(parentKey, Object.keys(next).length > 0 ? next : undefined); + }; + + const topChange = (key: string, next: t.ConfigValue): void => { + latestRef.current = { ...latestRef.current, [key]: next }; + onFieldChange(key, next); + }; + + const renderField = (key: string): ReactNode => { + const resolved = index.get(key); + if (!resolved) return null; + const { field, parentKey } = resolved; + const parentValue = parentKey ? asRecord(value[parentKey]) : value; + const parentPath = parentKey ? `${path}.${parentKey}` : path; + const change = parentKey ? nestedChange(parentKey) : topChange; + const dataPath = `${parentPath}.${field.key}`; + const current = parentValue[field.key]; + const write = (next: t.ConfigValue) => change(field.key, next); + const chips = + key === 'name' && fromLower + ? undefined + : (renderChips?.({ + path: dataPath, + label: labelForPath(dataPath, localize), + value: current, + onChange: write, + }) ?? undefined); + const required = !fromLower && REQUIRED_KEYS.has(key); + const common = { path: dataPath, field, value: current, chips, disabled, onChange: write }; + + if (key === 'name') { + const warning = issues.find( + (issue) => issue.field === 'name' && issue.severity === 'warning', + ); + return ( + + ); + } + if (key === 'apiKey' && fromLower && !storedSecret && lowerEntry?.apiKey === undefined) { + return ; + } + if (ENDPOINT_REFERENCE_KEYS.has(key)) { + const others = endpointNames.filter((other) => other !== name); + return ; + } + if (key === 'customParams.defaultParamsEndpoint') { + return ; + } + if (key === 'customParams.paramDefinitions') { + return ( + + ); + } + if (MODEL_REFERENCE_KEYS.has(key)) { + const endpoint = modelEndpoint(key, value, name); + const models = endpoint === name ? modelNames(value) : (modelsOf?.(endpoint) ?? []); + return ; + } + if (key === 'tokenConfig') { + return ( + + ); + } + if (key === 'dropParams') { + return ; + } + const shownField = required ? { ...field, isOptional: false } : field; + if (key === 'headers' && !disabled) { + return ( + + {renderInlineField(shownField, parentValue, parentPath, change, localize, disabled)} + + ); + } + return renderInlineField( + shownField, + parentValue, + parentPath, + change, + localize, + disabled, + undefined, + required, + editSessionId, + ); + }; + + const isShown = (def: t.EndpointFieldDef): boolean => { + if (!def.optional || shown.has(def.key)) return true; + const [head, child] = def.key.split('.'); + return hasValue(child === undefined ? value[head] : asRecord(value[head])[child]); + }; + + const warnings = new Map(); + for (const issue of issues) { + if (issue.severity !== 'warning' || issue.field === 'name') continue; + const section = sectionOfField(issue.field); + warnings.set(section, [...(warnings.get(section) ?? []), describeIssue(issue, localize)]); + } + + const layout: SectionLayout[] = ENDPOINT_SECTIONS.filter( + (section) => !essentialsOnly || ESSENTIAL_SECTION_IDS.has(section.id), + ).flatMap((section) => { + const defs = (section.catchAll ? unplacedKeys(index) : section.fields).filter((def) => + index.has(def.key), + ); + const visible = defs.filter(isShown); + const hidden = disabled ? [] : defs.filter((def) => !isShown(def)); + if (visible.length === 0 && hidden.length === 0) return []; + return [{ section, visible, hidden, warnings: warnings.get(section.id) ?? [] }]; + }); + + const errorCount = (sectionId: string): number => + issues.filter( + (issue) => issue.severity === 'error' && sectionOfField(issue.field) === sectionId, + ).length; + + const sectionDomId = (id: string) => `${toFieldId(path)}-section-${id}`; + + return ( + + +
+ {!essentialsOnly && layout.length > 1 && ( + + )} + {layout.map(({ section, visible, hidden, warnings: sectionWarnings }) => { + const headingId = `${sectionDomId(section.id)}-heading`; + return ( +
+
+

+ {localize(section.labelKey)} +

+ {section.descriptionKey && ( +

+ {localize(section.descriptionKey)} +

+ )} +
+ {sectionWarnings.map((warning) => ( + + {warning} + + ))} + {visible.length > 0 ? ( +
{visible.map((def) => renderField(def.key))}
+ ) : ( +

+ {localize('com_config_ce_section_nothing_set')} +

+ )} + {hidden.length > 0 && ( +
+
+ ({ + value: def.key, + label: labelForPath(`${path}.${def.key}`, localize), + }))} + placeholder={localize('com_config_ce_add_setting')} + onChange={(key) => { + setShown((prev) => new Set(prev).add(key)); + setFocusKey(key); + }} + aria-label={localize('com_config_ce_add_setting_a11y', { + section: localize(section.labelKey), + })} + /> +
+
+ )} +
+ ); + })} + {!essentialsOnly && } +
+
+
+ ); +} diff --git a/src/components/configuration/sections/endpoints/Fields.tsx b/src/components/configuration/sections/endpoints/Fields.tsx new file mode 100644 index 00000000..bd2b2771 --- /dev/null +++ b/src/components/configuration/sections/endpoints/Fields.tsx @@ -0,0 +1,520 @@ +/** + * Rows of the custom endpoint editor whose control the generic schema form + * cannot pick (WS10 CE-1, CE-5, CE-9): the name (locked for endpoints the + * layer beneath defines), the API key of a librechat.yaml endpoint (whose + * literal key LibreChat never sends back), and pickers for the endpoint or + * model another setting names. + */ +import { useId, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { EModelEndpoint, paramSettings } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { useLocalize, useFieldError, useFieldState, useOptionSources } from '@/hooks'; +import { layerRevertTarget, lowerLayerValue } from '../../renderer/defaults'; +import { SecretInput, SelectField, TextField } from '../../fields'; +import { fieldDescription } from '../../renderer/paths'; +import { DecorativeIcon } from '@/components/shared'; +import { fieldMetaFor } from '../../renderer/kinds'; +import { SettingsRow } from '../../SettingsRow'; +import { toFieldId } from '../../renderer'; +import { labelForPath } from '../../meta'; +import { TagListField } from './Request'; + +/** Built-in providers a custom endpoint can hand title or label generation to. */ +const BUILT_IN_PROVIDERS: readonly string[] = [ + EModelEndpoint.openAI, + EModelEndpoint.azureOpenAI, + EModelEndpoint.anthropic, + EModelEndpoint.google, + EModelEndpoint.bedrock, +]; + +const CURRENT_MODEL = 'current_model'; +const DEFAULT_PARAM_SET = EModelEndpoint.custom as string; + +/** `bedrock-anthropic` → "Bedrock · Anthropic"; built-ins use their provider label. */ +function paramSetLabel(key: string, localize: t.LocalizeFn): string { + if (key === DEFAULT_PARAM_SET) return localize('com_config_ce_params_openai_compatible'); + if (key === 'openrouter') return 'OpenRouter'; + const [provider, variant] = key.split('-'); + const base = labelForPath(`endpoints.${provider}`, localize); + if (!variant) return base; + return `${base} · ${variant.charAt(0).toUpperCase()}${variant.slice(1)}`; +} + +/** Parameter sets LibreChat ships (`paramSettings`), for "Parameters from". */ +function paramSetOptions(localize: t.LocalizeFn): t.SelectOption[] { + return Object.keys(paramSettings).map((key) => ({ + value: key, + label: paramSetLabel(key, localize), + })); +} + +/** Endpoints a setting can name: the built-in providers, then the custom endpoints, without repeats. */ +function endpointOptions( + names: readonly string[], + fromSource: readonly t.SelectOption[], + localize: t.LocalizeFn, +): t.SelectOption[] { + const seen = new Set(); + const options: t.SelectOption[] = []; + const add = (option: t.SelectOption) => { + if (option.value === '' || seen.has(option.value)) return; + seen.add(option.value); + options.push(option); + }; + for (const provider of BUILT_IN_PROVIDERS) { + add({ value: provider, label: labelForPath(`endpoints.${provider}`, localize) }); + } + for (const name of names) add({ value: name, label: name }); + for (const option of fromSource) add(option); + return options; +} + +/** What clearing a field goes back to: the layer beneath's value when it sets one (D3), else nothing. */ +function useClearTarget(path: string): t.RevertTarget | undefined { + const state = useFieldState(path); + return state.isOverride ? layerRevertTarget(state.revertTarget) : lowerLayerValue(state); +} + +interface RowProps { + /** Data path of the field. */ + path: string; + /** Its schema field (label meta and help text). */ + field: t.SchemaField; + required?: boolean; + chips?: ReactNode; + control: ReactNode; + /** Shown under the control, muted (a non-blocking warning, a lock note). */ + note?: ReactNode; +} + +/** A settings row of the endpoint editor with the field's label, help and inline error. */ +export function EditorRow({ path, field, required, chips, control, note }: RowProps) { + const localize = useLocalize(); + const error = useFieldError(path); + return ( + + {control} + {note &&
{note}
} + + } + /> + ); +} + +/** + * The endpoint's name. An endpoint the layer beneath defines keeps its name + * (D1: LibreChat merges by name, so a rename would add a second endpoint). + */ +export function NameRow({ + path, + field, + value, + locked, + lockNote, + warning, + required, + chips, + disabled, + onChange, +}: { + path: string; + field: t.SchemaField; + value: string; + locked: boolean; + lockNote: string; + warning?: string; + required?: boolean; + chips?: ReactNode; + disabled?: boolean; + onChange: (value: string) => void; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + if (locked || disabled) { + return ( + + {locked && ( + + + + )} + + {value} + +
+ } + note={locked ? lockNote : undefined} + /> + ); + } + return ( + + } + /> + ); +} + +/** + * The API key of an endpoint the layer beneath defines with a literal key: + * LibreChat redacts it on read and sends no preview, so it is not shown, not + * marked missing, and can only be replaced (CE-9). + */ +export function HiddenKeyRow({ + path, + field, + value, + chips, + disabled, + onChange, +}: { + path: string; + field: t.SchemaField; + value: t.ConfigValue; + chips?: ReactNode; + disabled?: boolean; + onChange: (value: t.ConfigValue) => void; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + const pending = typeof value === 'string'; + const [replacing, setReplacing] = useState(pending); + if (!replacing || disabled) { + return ( + + + {localize('com_config_ce_key_hidden')} + + {!disabled && ( + - - {renderCollapsible( - isExpanded, - hasEverExpanded, -
- {showsGlobalNotice && ( - - )} - {flattenGroupFields( - fields, - parentValue, - parentPath, - onChange, - localize, - disabled, - collectionRenderOverrides, - editSessionId, - lockedKeys, - )} -
, - )} - - ); -} - -export function GroupedFieldRenderer({ - groupKey, - fields, - parentValue, - parentPath, - onChange, - disabled, - collectionRenderOverrides, - editSessionId, - lockedKeys, -}: { - groupKey: string; - fields: t.SchemaField[]; - parentValue: t.ConfigValue; - parentPath: string; - onChange: (path: string, value: t.ConfigValue) => void; - disabled?: boolean; - collectionRenderOverrides?: Record; - editSessionId?: number; - /** Fields shown read-only (the name of a librechat.yaml endpoint, D1). */ - lockedKeys?: ReadonlySet; -}) { - const groups = FIELD_GROUPS[groupKey]; - if (!groups) return null; - - const allGroupedKeys = new Set(groups.flatMap((g) => g.fields)); - const fieldsByKey = new Map(fields.map((f) => [f.key, f])); - const ungrouped = fields.filter((f) => !allGroupedKeys.has(f.key)); - - return ( -
- {groups.map((group) => { - const groupFields = group.fields - .map((key) => fieldsByKey.get(key)) - .filter((f): f is t.SchemaField => f != null); - return ( - - ); - })} - {ungrouped.length > 0 && ( - - )} -
- ); -} diff --git a/src/components/configuration/sections/endpoints/List.tsx b/src/components/configuration/sections/endpoints/List.tsx new file mode 100644 index 00000000..3ea9f046 --- /dev/null +++ b/src/components/configuration/sections/endpoints/List.tsx @@ -0,0 +1,368 @@ +import { useEffect, useRef } from 'react'; +import type * as t from '@/types'; +import { DecorativeIcon, KebabMenu } from '@/components/shared'; +import { useLocalize, useValuesScope } from '@/hooks'; +import { sourceLabel } from '../../values'; +import { describeIssue } from './issues'; +import { isUnserved } from './model'; +import { cn } from '@/utils'; + +/** Two letters for the endpoint's monogram: initials of its words, else its first two characters. */ +function monogram(title: string): string { + const words = title.split(/[\s_-]+/).filter(Boolean); + if (words.length >= 2) return `${words[0][0]}${words[1][0]}`.toUpperCase(); + return title.slice(0, 2).toUpperCase(); +} + +function Monogram({ title, muted }: { title: string; muted?: boolean }) { + return ( + + ); +} + +/** A chip in a list row or the drawer header; `description` is read after the label. */ +export function Badge({ + tone, + label, + description, + dot, +}: { + tone: 'yaml' | 'override' | 'scope' | 'inherited' | 'added' | 'edited' | 'error' | 'warning'; + label: string; + description?: string; + dot?: boolean; +}) { + return ( + + {dot && + ); +} + +/** + * Where an endpoint comes from (CE-2): "librechat.yaml", "librechat.yaml + + * overrides", "Added in admin"; in a profile "Inherited from Base", "Base + + * Role: USER" or "Added in Role: USER". + */ +export function SourceBadge({ row }: { row: Pick }) { + const localize = useLocalize(); + const scope = useValuesScope(); + const scopeName = scope ? sourceLabel('scope', localize, scope) : ''; + switch (row.origin) { + case 'yaml': + return row.overridden ? ( + + ) : ( + + ); + case 'admin': + return ( + + ); + case 'inherited': + return row.overridden ? ( + + ) : ( + + ); + default: + return ( + + ); + } +} + +/** "Incomplete – not served", "Needs attention" or nothing (CE-3). */ +export function StatusBadge({ issues }: { issues: t.EndpointIssue[] }) { + const localize = useLocalize(); + const errors = issues.filter((issue) => issue.severity === 'error'); + if (errors.length === 0) return null; + const summary = errors.map((issue) => describeIssue(issue, localize)).join(' '); + return isUnserved(errors) ? ( + + ) : ( + + ); +} + +/** "New" or "Edited" for an endpoint with unsaved changes. */ +function PendingBadge({ row }: { row: t.EndpointRow }) { + const localize = useLocalize(); + if (row.isNew) { + return ( + + ); + } + if (!row.edited) return null; + return ( + + ); +} + +function rowDetails(row: t.EndpointRow, localize: t.LocalizeFn): string { + const parts = [row.host ?? localize('com_config_ce_no_base_url')]; + parts.push(localize('com_config_ce_models_count', { count: row.modelCount })); + if (row.fetchesModels) parts.push(localize('com_config_ce_models_fetched')); + return parts.join(' · '); +} + +/** Whether a row can be deleted here: endpoints the layer beneath defines can't (D1). */ +export function isDeletable(row: Pick): boolean { + return row.origin === 'admin' || row.origin === 'scope'; +} + +/** A saved endpoint a pending edit removes: struck through, with Undo (D9). */ +function RemovalRow({ + removal, + disabled, + onUndo, +}: { + removal: t.EndpointRemoval; + disabled?: boolean; + onUndo: (removal: t.EndpointRemoval) => void; +}) { + const localize = useLocalize(); + return ( +
  • + + + + {removal.title} + + + {localize('com_config_ctl_entry_pending_delete')} + + + {!disabled && ( + + )} +
  • + ); +} + +/** + * One endpoint of the list. Its row is the target the table of contents, + * search and the review's "Go to field" reveal: while its editor is closed it + * reads as collapsed (a closed grid marker), and the reveal's `config:expand` + * opens the editor. + */ +function EndpointListRow({ + row, + idPrefix, + isOpen, + disabled, + onOpen, + onReveal, + onDelete, + onRevert, +}: t.EndpointListRowProps) { + const localize = useLocalize(); + const rowRef = useRef(null); + const onRevealRef = useRef(onReveal); + onRevealRef.current = onReveal; + const detailsId = `${idPrefix}-${row.index}-details`; + + useEffect(() => { + const element = rowRef.current; + if (!element) return; + const reveal = () => onRevealRef.current(row); + element.addEventListener('config:expand', reveal); + return () => element.removeEventListener('config:expand', reveal); + }, [row]); + + const menu: t.KebabMenuItem[] = [ + { + label: localize(disabled ? 'com_config_ce_action_view' : 'com_config_ce_action_edit'), + icon: 'pencil', + onClick: () => onOpen(row), + }, + ]; + if (!disabled && row.overridden) { + menu.push({ + label: localize('com_config_ce_action_revert'), + icon: 'history', + onClick: () => onRevert(row), + }); + } + if (!disabled) { + const deletable = isDeletable(row); + menu.push({ + label: localize('com_config_ce_action_delete'), + icon: 'trash', + danger: deletable, + disabled: !deletable, + title: deletable + ? undefined + : localize( + row.origin === 'inherited' + ? 'com_config_ce_delete_locked_inherited' + : 'com_config_ce_delete_locked', + ), + onClick: () => onDelete(row), + }); + } + + return ( +
  • + {!isOpen && } + +
    +
    + + + + +
    + + + {rowDetails(row, localize)} + + {row.fetchesModels && ( + + + + )} + +
    +
    + +
    +
  • + ); +} + +/** + * The custom endpoint list (WS10 CE-7): one compact row per endpoint with its + * monogram, name, host, model count (and whether the list is fetched), where + * it comes from, its status and an actions menu. Selecting a row opens the + * editor. Pending deletions stay listed with Undo until Save (D9). + */ +export function EndpointList({ + rows, + removals, + openKey, + disabled, + onOpen, + onReveal, + onDelete, + onRevert, + onUndoRemoval, + idPrefix, +}: t.EndpointListProps) { + const localize = useLocalize(); + const items: Array< + { kind: 'row'; row: t.EndpointRow } | { kind: 'removed'; removal: t.EndpointRemoval } + > = rows.map((row) => ({ kind: 'row' as const, row })); + for (const removal of [...removals].sort((a, b) => a.index - b.index)) { + items.splice(Math.min(removal.index, items.length), 0, { kind: 'removed', removal }); + } + + return ( +
      + {items.map((item) => + item.kind === 'removed' ? ( + + ) : ( + + ), + )} +
    + ); +} diff --git a/src/components/configuration/sections/endpoints/ParamDefinitions.tsx b/src/components/configuration/sections/endpoints/ParamDefinitions.tsx index 631663e9..2aa4a829 100644 --- a/src/components/configuration/sections/endpoints/ParamDefinitions.tsx +++ b/src/components/configuration/sections/endpoints/ParamDefinitions.tsx @@ -1,10 +1,29 @@ +/** + * Parameter definitions of a custom endpoint (WS10 CE-6): the parameter + * panel its users see. An entry that only sets the default of a built-in + * parameter (`{ key, default }`) reads as such, with a default control of the + * parameter's own type; "Customize every option" opens the full definition, + * whose Default follows the chosen Type, whose Component choices fit that + * Type, and whose built-in (locale-coded) label and description are shown as + * such with a switch to custom text. Definitions librechat.yaml (or Base) + * defines keep their key: LibreChat would bring the original back. + */ +import { useMemo, useState } from 'react'; +import { Button } from '@clickhouse/click-ui'; +import { paramSettings } from 'librechat-data-provider'; +import type { SettingDefinition } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { renderInlineField } from '../../renderer'; +import { ArrayObjectField, ListField, NumberField, SelectField, TextField } from '../../fields'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { getEntryKey, isConfigRecord } from '@/utils'; +import { DecorativeIcon } from '@/components/shared'; +import { SettingsRow } from '../../SettingsRow'; +import { describeField, labelForPath } from '../../meta'; import { useLocalize } from '@/hooks'; -/** Maps type → valid component choices. */ -const TYPE_COMPONENTS: Record = { +/** Components that can show each type of parameter. */ +const TYPE_COMPONENTS: Readonly> = { number: ['input', 'slider'], boolean: ['checkbox', 'switch'], string: ['input', 'textarea'], @@ -12,131 +31,618 @@ const TYPE_COMPONENTS: Record = { array: ['tags'], }; -/** Maps component → extra fields that become relevant. */ -const COMPONENT_FIELDS: Record = { - input: ['minText', 'maxText', 'placeholder'], - textarea: ['minText', 'maxText', 'placeholder'], +/** Fields each component reads. */ +const COMPONENT_FIELDS: Readonly> = { + input: ['placeholder', 'minText', 'maxText'], + textarea: ['placeholder', 'minText', 'maxText'], slider: ['range', 'includeInput'], checkbox: [], switch: [], - dropdown: ['options'], - combobox: [ - 'options', - 'items', - 'searchPlaceholder', - 'selectPlaceholder', - 'searchPlaceholderCode', - 'selectPlaceholderCode', - ], + dropdown: ['options', 'enumMappings'], + combobox: ['options', 'searchPlaceholder', 'selectPlaceholder'], tags: ['minTags', 'maxTags'], }; -/** Fields always visible regardless of type/component. */ -const ALWAYS_VISIBLE = new Set(['key', 'type', 'component', 'description', 'default', 'label']); +/** Components whose options are a list of choices (a checkbox or switch ignores them). */ +const OPTION_COMPONENTS: ReadonlySet = new Set(['dropdown', 'combobox', 'slider']); -/** Explicit field ordering for paramDefinition entries. Fields listed here - * appear first in this order; unlisted fields follow in schema order. */ -const PARAM_FIELD_ORDER = [ +/** How the definition is laid out, after key, type, component and default. */ +const LAYOUT_FIELDS = ['optionType', 'showLabel', 'showDefault', 'columnSpan', 'columns']; + +/** Fields placed by hand (the rest render through the generic entry renderer). */ +const HANDLED: ReadonlySet = new Set([ 'key', - 'description', - 'label', 'type', 'component', 'default', - 'options', -]; + 'label', + 'labelCode', + 'description', + 'descriptionCode', +]); -/** Fields visible when options are relevant (enum, dropdown, combobox, checkbox, switch). */ -const OPTIONS_TYPES = new Set(['enum']); -const OPTIONS_COMPONENTS = new Set(['dropdown', 'combobox', 'checkbox', 'switch', 'slider']); +function asRecord(value: t.ConfigValue): t.ConfigRecord { + return isConfigRecord(value) ? value : {}; +} -function ParamDefinitionEntry({ - fields, - parentValue, - parentPath, - onChange, +function text(value: t.ConfigValue): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function displayValue(value: t.ConfigValue): string { + return Array.isArray(value) ? value.join(', ') : String(value); +} + +/** The built-in parameters of a parameter set, by key (LibreChat's `paramSettings`). */ +function builtInParameters(paramSet: string | undefined): Map { + const settings = paramSettings[paramSet ?? 'custom'] ?? paramSettings.custom ?? []; + return new Map(settings.map((setting) => [setting.key, setting])); +} + +function Row({ + path, + control, + description, + note, }: { - fields: t.SchemaField[]; - parentValue: t.ConfigValue; - parentPath: string; - onChange: (path: string, value: t.ConfigValue) => void; + path: string; + control: ReactNode; + description?: string; + note?: ReactNode; }) { const localize = useLocalize(); - const values = - typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; - - const currentType = typeof values.type === 'string' ? values.type : ''; - const currentComponent = typeof values.component === 'string' ? values.component : ''; + return ( + + {control} + {note &&
    {note}
    } + + } + /> + ); +} - // Build the set of visible field keys based on current type + component - const visibleKeys = new Set(ALWAYS_VISIBLE); +/** The default value, with a control for the parameter's type. */ +function DefaultControl({ + path, + type, + value, + options, + range, + onChange, + disabled, +}: { + path: string; + type: string | undefined; + value: t.ConfigValue; + options: string[]; + range: t.ConfigRecord; + onChange: (value: t.ConfigValue) => void; + disabled?: boolean; +}) { + const localize = useLocalize(); + const id = toFieldId(path); + const label = labelForPath(path, localize); + const clear = () => { + onChange(undefined); + return true; + }; + if (disabled) { + return ( + + {value === undefined || value === '' + ? localize('com_config_ce_param_no_default') + : displayValue(value)} + + ); + } + switch (type) { + case 'boolean': + return ( + onChange(next === 'true')} + onClear={clear} + defaultLabel={localize('com_config_ce_param_no_default')} + width="S" + aria-label={label} + /> + ); + case 'number': + return ( + onChange(next)} + onClear={clear} + min={typeof range.min === 'number' ? range.min : undefined} + max={typeof range.max === 'number' ? range.max : undefined} + step={typeof range.step === 'number' ? range.step : undefined} + width="S" + aria-label={label} + /> + ); + case 'enum': + return ( + ({ value: option, label: option }))} + onChange={onChange} + onClear={clear} + defaultLabel={localize('com_config_ce_param_no_default')} + width="M" + aria-label={label} + /> + ); + case 'array': + return ( + typeof item === 'string') : []} + onChange={onChange} + onClear={clear} + width="L" + aria-label={label} + /> + ); + default: + return ( + + ); + } +} - if (currentType && OPTIONS_TYPES.has(currentType)) { - visibleKeys.add('options'); - visibleKeys.add('enumMappings'); +/** + * A label or description LibreChat looks up in its own translations + * (`labelCode`): shown as the built-in text, with a switch to custom text. + */ +function CodedText({ + path, + codeKey, + values, + onChange, + disabled, + wide, +}: { + path: string; + codeKey: string; + values: t.ConfigRecord; + onChange: (key: string, value: t.ConfigValue) => void; + disabled?: boolean; + wide?: boolean; +}) { + const localize = useLocalize(); + const key = path.split('.').pop() ?? ''; + const id = toFieldId(path); + const current = text(values[key]); + if (values[codeKey] === true) { + return ( + + + {localize('com_config_ce_param_builtin_text')} + + {!disabled && ( + + )} + + ))} + + )} + {!disabled && ( + <> + { + const next = event.target.value; + if (next.endsWith(',')) add(next); + else setDraft(next); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + add(draft); + } + if (event.key === 'Backspace' && draft === '' && values.length > 0) { + remove(values[values.length - 1]); + } + }} + onBlur={() => add(draft)} + placeholder={localize('com_config_ce_drop_params_placeholder')} + aria-label={ariaLabel} + className={cn(INPUT_CLASSES, 'max-w-80')} + /> + + {COMMON_DROP_PARAMS.filter((param) => !taken.has(param)).map((param) => ( + + + )} + + ); +} + +function isTextInput(element: EventTarget | null): element is HTMLInputElement { + return element instanceof HTMLInputElement && element.type === 'text'; +} + +/** Types `text` into a React-controlled input at its cursor, as if the admin had typed it. */ +function insertAtCursor(input: HTMLInputElement, text: string) { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? start; + const next = `${input.value.slice(0, start)}${text}${input.value.slice(end)}`; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, next); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + input.setSelectionRange(start + text.length, start + text.length); +} + +/** + * The headers editor with LibreChat's placeholders under it: picking one + * inserts it at the cursor of the header value last edited (or copies it + * when no header value has been focused yet). + */ +export function HeaderPlaceholders({ children }: { children: ReactNode }) { + const localize = useLocalize(); + const lastInputRef = useRef(null); + const [copied, setCopied] = useState(null); + + const insert = (placeholder: string) => { + const input = lastInputRef.current; + if (input && input.isConnected && !input.disabled) { + insertAtCursor(input, placeholder); + setCopied(null); + return; + } + void navigator.clipboard?.writeText(placeholder).then( + () => setCopied(placeholder), + () => setCopied(null), + ); + }; + + return ( +
    { + if (isTextInput(event.target)) lastInputRef.current = event.target; + }} + > + {children} +
    + +
    + ); +} diff --git a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx index ae9b0e51..4c0db593 100644 --- a/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx +++ b/src/components/configuration/sections/endpoints/__tests__/CustomEndpointsRenderer.test.tsx @@ -1,36 +1,242 @@ -import { describe, it, expect, vi } from 'vitest'; +import { useState } from 'react'; import { configSchema } from 'librechat-data-provider'; -import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent, within, act } from '@testing-library/react'; import type * as t from '@/types'; +import { + valuesDataFixture, + baseConfigFixture, + baseLayersFixture, + scopeLayersFixture, +} from '@/test/layers'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; import { CustomEndpointsRenderer } from '../CustomEndpointsRenderer'; import { extractSchemaTree } from '@/server/config'; -vi.mock('@/hooks/useLocalize', () => ({ - default: () => (key: string) => key, - useLocalize: () => (key: string) => key, -})); +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +/** jsdom has no ResizeObserver; Radix checkboxes inside a form measure themselves with one. */ +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', NoopResizeObserver); +}); const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); -const endpoints = tree.find((field) => field.key === 'endpoints'); +const fields = tree.find((field) => field.key === 'endpoints')?.children ?? []; + +const ADMIN_ENTRY: t.ConfigRecord = { + name: 'Mine', + apiKey: 'user_provided', + baseURL: 'https://mine.example.com/v1', + models: { default: ['m-1'] }, +}; + +/** Base: librechat.yaml's GPT and Claude, GPT's base URL overridden, and `Mine` added in the panel. */ +function baseLayers(): t.ValueLayers { + return baseLayersFixture( + baseConfigFixture({ + dbOverrides: { + endpoints: { + custom: [{ name: 'GPT', baseURL: 'https://override.example.com' }, ADMIN_ENTRY], + }, + }, + }), + ); +} + +function savedList(layers: t.ValueLayers): t.ConfigValue[] { + const endpoints = layers.effective?.endpoints; + const list = + endpoints && typeof endpoints === 'object' && !Array.isArray(endpoints) + ? endpoints.custom + : undefined; + return Array.isArray(list) ? list : []; +} + +function Harness({ + layers, + initial, + onChange, + collectionEdits = {}, +}: { + layers: t.ValueLayers; + initial?: t.ConfigValue[]; + onChange?: (path: string, value: t.ConfigValue) => void; + collectionEdits?: t.CollectionEdits; +}) { + const [items, setItems] = useState(initial ?? savedList(layers)); + const handleChange = (path: string, value: t.ConfigValue) => { + onChange?.(path, value); + if (path === 'endpoints.custom' && Array.isArray(value)) setItems(value); + const match = /^endpoints\.custom\.(\d+)$/.exec(path); + if (match) setItems((prev) => prev.map((item, i) => (i === Number(match[1]) ? value : item))); + }; + return ( + + + (path === 'endpoints.custom' ? items : fallback)} + onChange={handleChange} + /> + + + ); +} + +function row(name: string): HTMLElement { + const element = document.querySelector(`li[data-endpoint-row="${name}"]`); + if (!element) throw new Error(`no row for ${name}`); + return element; +} + +function openRow(name: string): HTMLElement { + fireEvent.click(within(row(name)).getByRole('button', { name })); + return screen.getByRole('dialog'); +} + +async function typeInto(selector: string, text: string) { + const input = document.querySelector(selector); + if (!input) throw new Error(`no ${selector}`); + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); + await act(async () => { + vi.runOnlyPendingTimers(); + }); +} describe('CustomEndpointsRenderer', () => { + it('lists each endpoint with where it comes from and its host', () => { + render(); + expect(within(row('GPT')).getByText('com_config_ce_source_yaml_overrides')).toBeInTheDocument(); + expect(within(row('Claude')).getByText('com_config_ce_source_yaml')).toBeInTheDocument(); + expect(within(row('Mine')).getByText('com_config_ce_source_admin')).toBeInTheDocument(); + expect(within(row('GPT')).getByText(/override\.example\.com/)).toBeInTheDocument(); + expect(within(row('Mine')).queryByText('com_config_ce_status_unserved')).toBeNull(); + }); + + it('marks an endpoint LibreChat would not serve (CE-3)', () => { + render(); + expect(within(row('OnlyName')).getByText('com_config_ce_status_unserved')).toBeInTheDocument(); + }); + + it('keeps the name of a librechat.yaml endpoint read-only and offers no delete (D1)', () => { + render(); + const drawer = openRow('Claude'); + const name = drawer.querySelector('#endpoints-custom-1-name'); + expect(name?.tagName).not.toBe('INPUT'); + expect(name).toHaveTextContent('Claude'); + expect( + within(drawer).queryByRole('button', { name: 'com_config_ce_action_delete' }), + ).toBeNull(); + }); + + it('lets an endpoint added in the panel be renamed and deleted', () => { + const onChange = vi.fn(); + render(); + const drawer = openRow('Mine'); + expect(drawer.querySelector('input#endpoints-custom-2-name')).not.toBeDisabled(); + fireEvent.click(within(drawer).getByRole('button', { name: 'com_config_ce_action_delete' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [ + expect.objectContaining({ name: 'GPT' }), + expect.objectContaining({ name: 'Claude' }), + ]); + }); + it('leaves a cleared name editable when the layer beneath defines no endpoints', () => { - const { container } = render( - fallback} - onChange={vi.fn()} - />, + const layers = baseLayersFixture( + baseConfigFixture({ yamlConfig: {}, config: {}, yamlPaths: [], dbOverrides: {} }), + ); + render( + , ); - const toggle = container.querySelector( - '[data-entry-card] button[aria-expanded]', + fireEvent.click(document.querySelector('li[data-endpoint-row] button') as HTMLElement); + expect(document.querySelector('input#endpoints-custom-0-name')).not.toBeDisabled(); + }); + + it('lists a pending deletion from the edit state, with Undo (D9)', () => { + const onChange = vi.fn(); + const layers = baseLayers(); + const saved = savedList(layers); + const remaining = saved.slice(0, 2); + const edits: t.CollectionEdits = { + 'endpoints.custom': { + baseline: saved, + origins: remaining.map((entry, i) => ({ + identity: ['GPT', 'Claude'][i], + baseline: entry, + })), + detached: [{ value: saved[2], origin: { identity: 'Mine', baseline: saved[2] } }], + }, + }; + render( + , ); - if (!toggle) throw new Error('no entry card'); - fireEvent.click(toggle); - const name = container.querySelector('#endpoints-custom-0-name'); - expect(name).not.toBeNull(); - expect(name).not.toBeDisabled(); + expect(screen.getByText('com_config_ctl_entry_pending_delete')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_ctl_entry_undo_a11y:Mine' })); + expect(onChange).toHaveBeenLastCalledWith('endpoints.custom', [...remaining, saved[2]]); + }); + + it('opens an inherited endpoint read-only until it is overridden for the profile (CE-8)', () => { + render(); + const drawer = openRow('Claude'); + const baseURL = () => drawer.querySelector('#endpoints-custom-1-baseURL'); + expect(baseURL()?.tagName).not.toBe('INPUT'); + fireEvent.click(within(drawer).getByRole('button', { name: 'com_config_ce_override_for' })); + expect(baseURL()?.tagName).toBe('INPUT'); + }); + + it('keeps Create disabled until the endpoint can be served, then adds it', async () => { + vi.useFakeTimers(); + try { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_config_create_endpoint' })); + fireEvent.click(screen.getByRole('button', { name: /com_config_ce_template_blank_name/ })); + const create = screen.getByRole('button', { name: 'com_config_ce_create' }); + expect(create).toBeDisabled(); + + await typeInto('#endpoints-custom-9999-name', 'openAI'); + expect(screen.getByText('com_config_ce_issue_name_reserved')).toBeInTheDocument(); + await typeInto('#endpoints-custom-9999-name', 'New One'); + await typeInto('#endpoints-custom-9999-baseURL', 'https://new.example.com/v1'); + await typeInto('#endpoints-custom-9999-apiKey', '${NEW_KEY}'); + const models = document.querySelector( + '#endpoints-custom-9999-models-default input:not([type="hidden"])', + ); + if (!models) throw new Error('no model input'); + fireEvent.change(models, { target: { value: 'new-model' } }); + fireEvent.keyDown(models, { key: 'Enter' }); + await act(async () => { + vi.runOnlyPendingTimers(); + }); + expect(create).not.toBeDisabled(); + fireEvent.click(create); + expect(onChange).toHaveBeenLastCalledWith( + 'endpoints.custom', + expect.arrayContaining([ + { + name: 'New One', + baseURL: 'https://new.example.com/v1', + apiKey: '${NEW_KEY}', + models: { default: ['new-model'] }, + }, + ]), + ); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx new file mode 100644 index 00000000..f93f036e --- /dev/null +++ b/src/components/configuration/sections/endpoints/__tests__/controls.test.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { configSchema } from 'librechat-data-provider'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import type * as t from '@/types'; +import { ParamDefinitionsRow } from '../ParamDefinitions'; +import { extractSchemaTree } from '@/server/config'; +import { TagListField } from '../Request'; +import { PricingRow } from '../Pricing'; + +vi.mock('@/hooks/useLocalize', () => { + const localize = (key: string, params?: Record) => + params?.name ? `${key}:${params.name}` : key; + return { default: () => localize, useLocalize: () => localize }; +}); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', NoopResizeObserver); + Element.prototype.scrollIntoView = vi.fn(); +}); + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const custom = tree + .find((field) => field.key === 'endpoints') + ?.children?.find((field) => field.key === 'custom'); +const customParams = custom?.children?.find((field) => field.key === 'customParams'); +const paramDefinitionsField = customParams?.children?.find( + (field) => field.key === 'paramDefinitions', +); +const PATH = 'endpoints.custom.0.customParams.paramDefinitions'; + +function Params({ + initial, + lowerKeys = new Set(), + onChange, +}: { + initial: t.ConfigValue[]; + lowerKeys?: ReadonlySet; + onChange?: (value: t.ConfigValue) => void; +}) { + const [value, setValue] = useState(initial); + if (!paramDefinitionsField) throw new Error('no paramDefinitions field'); + return ( + { + onChange?.(next); + setValue(next); + }} + /> + ); +} + +function expandFirstCard(container: HTMLElement) { + const header = container.querySelector( + '[data-entry-card] button[aria-expanded]', + ); + if (!header) throw new Error('no card'); + if (header.getAttribute('aria-expanded') !== 'true') fireEvent.click(header); +} + +describe('ParamDefinitionsRow (CE-6)', () => { + it('titles a definition by its key and shows its default with a control of its type', () => { + const { container } = render( + , + ); + expect(screen.getByText('useResponsesApi')).toBeInTheDocument(); + expandFirstCard(container); + const defaultSelect = container.querySelector( + `#${PATH.replace(/\./g, '-')}-0-default`, + ); + expect(defaultSelect?.value).toBe('true'); + expect(screen.getAllByText('com_config_ce_param_builtin_text').length).toBeGreaterThan(0); + }); + + it('changes the type in one update that drops a component and default that no longer fit', () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + expandFirstCard(container); + const type = container.querySelector(`#${PATH.replace(/\./g, '-')}-0-type`); + if (!type) throw new Error('no type select'); + fireEvent.change(type, { target: { value: 'number' } }); + expect(onChange).toHaveBeenLastCalledWith([{ key: 'flag', type: 'number' }]); + const component = container.querySelector( + `#${PATH.replace(/\./g, '-')}-0-component`, + ); + const choices = [...(component?.options ?? [])].map((option) => option.value); + expect(choices).toContain('slider'); + expect(choices).not.toContain('switch'); + }); + + it('keeps the key of a definition the layer beneath defines, and offers no delete', () => { + const { container } = render( + , + ); + expandFirstCard(container); + expect(container.querySelector(`input#${PATH.replace(/\./g, '-')}-0-key`)).toBeNull(); + expect(screen.getByText('com_config_ce_param_key_locked')).toBeInTheDocument(); + expect(container.querySelector('button[aria-label^="com_ui_delete"]')).toBeNull(); + }); + + it('adds an override of a built-in parameter with its built-in default', () => { + const onChange = vi.fn(); + render(); + const select = screen.getByLabelText('com_config_ce_param_add_builtin'); + fireEvent.change(select, { target: { value: 'temperature' } }); + const [added] = onChange.mock.lastCall?.[0] as t.ConfigRecord[]; + expect(added.key).toBe('temperature'); + expect(typeof added.default).toBe('number'); + }); +}); + +describe('TagListField', () => { + it('adds comma-separated names, skips repeats and clears when the last is removed', () => { + const onChange = vi.fn(); + const onClear = vi.fn(); + const { rerender } = render( + , + ); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'user, stop, top_p' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenLastCalledWith(['stop', 'user', 'top_p']); + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_remove_item:stop' })); + expect(onClear).toHaveBeenCalled(); + }); +}); + +describe('PricingRow', () => { + it('edits rates per model and keeps models the layer beneath prices', () => { + const onChange = vi.fn(); + render( + , + ); + const table = screen.getByRole('table'); + expect(within(table).getAllByRole('row')).toHaveLength(3); + expect(screen.getByText('com_config_ce_pricing_missing')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'com_ui_remove_item:model-a' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_remove_item:model-b' })); + expect(onChange).toHaveBeenLastCalledWith({ + 'model-a': { prompt: 1, completion: 2, context: 1000 }, + }); + const add = screen.getByPlaceholderText('com_config_ce_pricing_add_placeholder'); + fireEvent.change(add, { target: { value: 'model-c' } }); + fireEvent.keyDown(add, { key: 'Enter' }); + expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ 'model-c': {} })); + }); +}); diff --git a/src/components/configuration/sections/endpoints/__tests__/model.test.ts b/src/components/configuration/sections/endpoints/__tests__/model.test.ts new file mode 100644 index 00000000..d4624aa8 --- /dev/null +++ b/src/components/configuration/sections/endpoints/__tests__/model.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import type * as t from '@/types'; +import { + modelNames, + isUnserved, + endpointHost, + isUsableBaseURL, + validateEndpoint, + hasBlockingIssue, +} from '../model'; + +const complete: t.ConfigRecord = { + name: 'OpenRouter', + apiKey: '${OPENROUTER_KEY}', + baseURL: 'https://openrouter.ai/api/v1', + models: { default: ['openai/gpt-4o-mini'] }, +}; + +function codes(input: Partial & { entry: t.ConfigValue }): string[] { + return validateEndpoint({ + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + ...input, + }).map((issue) => issue.code); +} + +describe('validateEndpoint', () => { + it('accepts a complete endpoint', () => { + expect(codes({ entry: complete })).toEqual([]); + }); + + it('flags a name-only endpoint as not served (custom-08)', () => { + const issues = validateEndpoint({ + entry: { name: 'OnlyName' }, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => issue.code)).toEqual([ + 'apiKeyRequired', + 'baseUrlRequired', + 'modelsRequired', + ]); + expect(isUnserved(issues)).toBe(true); + expect(hasBlockingIssue(issues)).toBe(true); + }); + + it('requires a default model even when the list is fetched, but serves it', () => { + const entry = { ...complete, models: { default: [], fetch: true } }; + const issues = validateEndpoint({ + entry, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => issue.code)).toEqual(['modelsRequired']); + expect(isUnserved(issues)).toBe(false); + }); + + it('rejects reserved, padded, blank and taken names', () => { + expect(codes({ entry: { ...complete, name: 'openAI' } })).toEqual(['nameReserved']); + expect(codes({ entry: { ...complete, name: ' GPT' } })).toEqual(['nameWhitespace']); + expect(codes({ entry: { ...complete, name: ' ' } })).toEqual(['nameRequired']); + expect(codes({ entry: complete, otherNames: ['OpenRouter'] })).toEqual(['nameTaken']); + }); + + it('only warns about a name that differs in letter case', () => { + const issues = validateEndpoint({ + entry: complete, + otherNames: ['openrouter'], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues).toEqual([ + { field: 'name', code: 'nameSimilar', severity: 'warning', params: { name: 'openrouter' } }, + ]); + }); + + it('flags blank models and warns about repeated ones (custom-09)', () => { + const entry = { + ...complete, + models: { default: ['@cf/gemma', '', '@cf/gemma', { name: 'kimi', description: 'K' }] }, + }; + const issues = validateEndpoint({ + entry, + otherNames: [], + fromLowerLayer: false, + storedSecret: false, + }); + expect(issues.map((issue) => [issue.code, issue.severity])).toEqual([ + ['modelBlank', 'error'], + ['modelDuplicate', 'warning'], + ]); + expect(issues[1].params).toEqual({ models: '@cf/gemma' }); + }); + + it('does not ask for the key or name of an endpoint librechat.yaml defines (CE-9)', () => { + const yamlEntry = { + name: 'Ollama', + baseURL: 'http://ollama:11434/v1', + models: { default: ['x'] }, + }; + expect(codes({ entry: yamlEntry, fromLowerLayer: true, otherNames: ['Ollama'] })).toEqual([]); + }); + + it('counts a stored key and asks for it again on rename (CE-1)', () => { + const stored = { ...complete, apiKey: undefined, apiKeyPreview: '...0XYZ' }; + expect(codes({ entry: stored, storedSecret: true, loadedName: 'OpenRouter' })).toEqual([]); + expect( + codes({ + entry: { ...stored, name: 'Renamed' }, + storedSecret: true, + loadedName: 'OpenRouter', + }), + ).toEqual(['renameNeedsKey']); + expect( + codes({ + entry: { ...stored, name: 'Renamed', apiKey: 'sk-new' }, + storedSecret: true, + loadedName: 'OpenRouter', + }), + ).toEqual([]); + }); + + it('warns about a base URL that is not a URL, but accepts env references', () => { + expect(codes({ entry: { ...complete, baseURL: 'api.example.com/v1' } })).toEqual([ + 'baseUrlInvalid', + ]); + expect(codes({ entry: { ...complete, baseURL: '${OLLAMA_URL}' } })).toEqual([]); + expect(codes({ entry: { ...complete, baseURL: 'user_provided' } })).toEqual([]); + }); +}); + +describe('list row helpers', () => { + it('shows the host and path without the scheme, env references as written', () => { + expect(endpointHost('https://api.moonshot.ai/v1/')).toBe('api.moonshot.ai/v1'); + expect(endpointHost('https://gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/x')).toBe( + 'gateway.ai.cloudflare.com/v1/${CF_ACCOUNT_ID}/x', + ); + expect(endpointHost(undefined)).toBeNull(); + }); + + it('reads model names from both model forms', () => { + expect(modelNames({ models: { default: ['a', { name: 'b', description: 'B' }] } })).toEqual([ + 'a', + 'b', + ]); + }); + + it('recognizes usable base URLs', () => { + expect(isUsableBaseURL('http://host.docker.internal:11434/v1')).toBe(true); + expect(isUsableBaseURL('ftp://x')).toBe(false); + }); +}); diff --git a/src/components/configuration/sections/endpoints/groups.ts b/src/components/configuration/sections/endpoints/groups.ts index 25b57a32..98e0779a 100644 --- a/src/components/configuration/sections/endpoints/groups.ts +++ b/src/components/configuration/sections/endpoints/groups.ts @@ -1,129 +1,154 @@ -import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { renderInlineField } from '../../renderer'; /** - * Defines how fields are grouped inside custom endpoint cards and the create - * dialog. Fields not listed in any group are collected into a catch-all - * "Advanced" group (collapsed by default). - * - * To apply this pattern to other collection types, add a new entry keyed by - * the collection's schema parent key and define the groups. + * Sections of the custom endpoint editor (WS10 CE-7), by purpose. Fields + * marked `optional` are shown only when set (or added from "Add setting"), + * so an endpoint reads as what it configures rather than every option + * LibreChat has. Schema keys no section lists land in "Other settings", so + * new LibreChat fields still appear without a change here. */ -export const FIELD_GROUPS: Record = { - custom: [ - { - labelKey: 'com_config_group_connection', - fields: ['name', 'apiKey', 'baseURL', 'iconURL'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_models', - fields: ['models', 'modelDisplayLabel'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_title_generation', - fields: [ - 'titleConvo', - 'titleModel', - 'titleMethod', - 'titlePrompt', - 'titlePromptTemplate', - 'titleEndpoint', - 'titleMessageRole', - ], - defaultExpanded: false, - }, - { - labelKey: 'com_config_group_summarization', - fields: ['summarize', 'summaryModel'], - defaultExpanded: false, - }, - ], -}; +export const ENDPOINT_SECTIONS: t.EndpointSectionDef[] = [ + { + id: 'connection', + labelKey: 'com_config_ce_section_connection', + descriptionKey: 'com_config_ce_section_connection_desc', + fields: [ + { key: 'name' }, + { key: 'baseURL' }, + { key: 'apiKey' }, + { key: 'provider' }, + { key: 'iconURL' }, + ], + }, + { + id: 'models', + labelKey: 'com_config_ce_section_models', + descriptionKey: 'com_config_ce_section_models_desc', + fields: [ + { key: 'models.default' }, + { key: 'models.fetch' }, + { key: 'models.userIdQuery' }, + { key: 'modelDisplayLabel' }, + ], + }, + { + id: 'request', + labelKey: 'com_config_ce_section_request', + descriptionKey: 'com_config_ce_section_request_desc', + fields: [ + { key: 'headers' }, + { key: 'addParams' }, + { key: 'dropParams' }, + { key: 'directEndpoint' }, + ], + }, + { + id: 'behaviour', + labelKey: 'com_config_ce_section_behaviour', + descriptionKey: 'com_config_ce_section_behaviour_desc', + fields: [ + { key: 'titleConvo' }, + { key: 'titleModel' }, + { key: 'titleMethod' }, + { key: 'titleEndpoint', optional: true }, + { key: 'titleTiming', optional: true }, + { key: 'titlePrompt', optional: true }, + { key: 'titlePromptTemplate', optional: true }, + { key: 'titleMessageRole', optional: true }, + { key: 'streamRate', optional: true }, + { key: 'maxToolResultChars', optional: true }, + ], + }, + { + id: 'labels', + labelKey: 'com_config_ce_section_labels', + descriptionKey: 'com_config_ce_section_labels_desc', + fields: [ + { key: 'activityLabel', optional: true }, + { key: 'activityModel', optional: true }, + { key: 'activityEndpoint', optional: true }, + { key: 'activityPrompt', optional: true }, + { key: 'activityMaxPerRun', optional: true }, + { key: 'activityCharLimit', optional: true }, + { key: 'activityPhaseLabel', optional: true }, + { key: 'activityPhaseModel', optional: true }, + { key: 'activityPhaseEndpoint', optional: true }, + { key: 'activityPhasePrompt', optional: true }, + { key: 'activityPhaseMaxPerRun', optional: true }, + { key: 'reasoningLabel', optional: true }, + { key: 'reasoningLabelModel', optional: true }, + { key: 'reasoningLabelEndpoint', optional: true }, + { key: 'reasoningLabelPrompt', optional: true }, + { key: 'reasoningLabelMinChars', optional: true }, + { key: 'reasoningLabelUpdateChars', optional: true }, + { key: 'reasoningLabelUpdateIntervalMs', optional: true }, + { key: 'reasoningLabelMaxPerRun', optional: true }, + { key: 'customParams.reasoningFormat', optional: true }, + { key: 'customParams.reasoningKey', optional: true }, + { key: 'customParams.includeReasoningContent', optional: true }, + { key: 'customParams.includeReasoningHistory', optional: true }, + ], + }, + { + id: 'parameters', + labelKey: 'com_config_ce_section_parameters', + descriptionKey: 'com_config_ce_section_parameters_desc', + fields: [ + { key: 'customParams.defaultParamsEndpoint' }, + { key: 'customParams.paramDefinitions' }, + ], + }, + { + id: 'pricing', + labelKey: 'com_config_ce_section_pricing', + descriptionKey: 'com_config_ce_section_pricing_desc', + fields: [{ key: 'tokenConfig' }], + }, + { + id: 'other', + labelKey: 'com_config_ce_section_other', + fields: [], + catchAll: true, + }, +]; -/** - * Fields that are effectively required for a functional custom endpoint. - * The config schema uses `endpointSchema.partial()` which makes everything - * optional at the Zod level, but these fields are needed for an endpoint - * to actually work. We override `isOptional` for these keys so the UI - * shows required indicators. - */ -const REQUIRED_ENDPOINT_KEYS = new Set(['name', 'apiKey', 'baseURL']); +/** The sections a new endpoint starts with: what LibreChat needs to serve it. */ +export const ESSENTIAL_SECTION_IDS: ReadonlySet = new Set(['connection', 'models']); -function withRequired(field: t.SchemaField): t.SchemaField { - if (REQUIRED_ENDPOINT_KEYS.has(field.key)) { - return { ...field, isOptional: false }; - } - if (field.key === 'default' && field.path?.endsWith('models.default')) { - return { ...field, isOptional: false }; - } - return field; -} +/** Entry keys never edited directly: the stored key's masked preview travels with the key. */ +export const HIDDEN_ENDPOINT_KEYS: ReadonlySet = new Set(['apiKeyPreview']); -/** - * Flatten a field's children into the group when the field is a nested object. - * This prevents double-collapsible nesting (e.g. Models group > models collapsible). - * The children are rendered with a prefixed onChange that writes back into the - * parent object at the correct key. - */ -export function flattenGroupFields( - fields: t.SchemaField[], - parentValue: t.ConfigValue, - parentPath: string, - onChange: (path: string, value: t.ConfigValue) => void, - localize: (key: string, interpolation?: Record) => string, - disabled?: boolean, - collectionRenderOverrides?: Record, - editSessionId?: number, - lockedKeys?: ReadonlySet, -): ReactNode[] { - const values = - typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue) - ? (parentValue as Record) - : {}; +/** Fields that name another endpoint (titles and labels can be generated by a different one). */ +export const ENDPOINT_REFERENCE_KEYS: ReadonlySet = new Set([ + 'titleEndpoint', + 'activityEndpoint', + 'activityPhaseEndpoint', + 'reasoningLabelEndpoint', +]); + +/** Fields that name a model of this endpoint (or `current_model`). */ +export const MODEL_REFERENCE_KEYS: ReadonlySet = new Set([ + 'titleModel', + 'activityModel', + 'activityPhaseModel', + 'reasoningLabelModel', +]); + +/** Top-level entry keys the sections place (a nested key places its parent). */ +export const PLACED_TOP_KEYS: ReadonlySet = new Set( + ENDPOINT_SECTIONS.flatMap((section) => section.fields.map((field) => field.key.split('.')[0])), +); - const nodes: ReactNode[] = []; - for (const field of fields) { - if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') { - const nested = values[field.key]; - const nestedObj = - typeof nested === 'object' && nested !== null && !Array.isArray(nested) - ? (nested as Record) - : {}; - for (const child of field.children) { - nodes.push( - renderInlineField( - withRequired(child), - nested, - `${parentPath}.${field.key}`, - (childKey, childValue) => { - onChange(field.key, { ...nestedObj, [childKey]: childValue }); - }, - localize, - disabled, - collectionRenderOverrides, - true, - editSessionId, - ), - ); - } - } else { - nodes.push( - renderInlineField( - withRequired(field), - parentValue, - parentPath, - onChange, - localize, - disabled || (lockedKeys?.has(field.key) ?? false), - collectionRenderOverrides, - true, - editSessionId, - ), - ); +/** The section an entry field (`models.default`, `customParams.paramDefinitions.0.key`) is edited in. */ +export function sectionOfField(fieldPath: string): string { + for (const section of ENDPOINT_SECTIONS) { + for (const field of section.fields) { + if (fieldPath === field.key || fieldPath.startsWith(`${field.key}.`)) return section.id; } } - return nodes; + const top = fieldPath.split('.')[0]; + const owner = ENDPOINT_SECTIONS.find((section) => + section.fields.some((field) => field.key.split('.')[0] === top), + ); + return owner?.id ?? 'other'; } diff --git a/src/components/configuration/sections/endpoints/index.ts b/src/components/configuration/sections/endpoints/index.ts index 9360fc83..547d9ef6 100644 --- a/src/components/configuration/sections/endpoints/index.ts +++ b/src/components/configuration/sections/endpoints/index.ts @@ -1,7 +1,6 @@ /** - * Custom Endpoints tab: renders the `endpoints.custom` array — each entry is - * an expandable card with field ordering and progressive disclosure, plus a - * "Create Endpoint" button that opens a dialog for building new entries - * without inline render lag. New schema fields surface automatically. + * Custom Endpoints tab (WS10): a compact list of `endpoints.custom` with an + * editor drawer per endpoint and a create flow with provider templates. New + * schema fields appear in the editor's "Other settings" automatically. */ export { CustomEndpointsRenderer } from './CustomEndpointsRenderer'; diff --git a/src/components/configuration/sections/endpoints/issues.ts b/src/components/configuration/sections/endpoints/issues.ts new file mode 100644 index 00000000..7c84176d --- /dev/null +++ b/src/components/configuration/sections/endpoints/issues.ts @@ -0,0 +1,21 @@ +import type * as t from '@/types'; + +const ISSUE_KEYS: Readonly> = { + nameRequired: 'com_config_ce_issue_name_required', + nameWhitespace: 'com_config_ce_issue_name_whitespace', + nameTaken: 'com_config_ce_issue_name_taken', + nameSimilar: 'com_config_ce_issue_name_similar', + nameReserved: 'com_config_ce_issue_name_reserved', + apiKeyRequired: 'com_config_ce_issue_api_key_required', + baseUrlRequired: 'com_config_ce_issue_base_url_required', + baseUrlInvalid: 'com_config_ce_issue_base_url_invalid', + modelsRequired: 'com_config_ce_issue_models_required', + modelBlank: 'com_config_ce_issue_model_blank', + modelDuplicate: 'com_config_ce_issue_model_duplicate', + renameNeedsKey: 'com_config_ce_issue_rename_needs_key', +}; + +/** The sentence an endpoint problem is shown as, at its field or in the list. */ +export function describeIssue(issue: t.EndpointIssue, localize: t.LocalizeFn): string { + return localize(ISSUE_KEYS[issue.code], issue.params); +} diff --git a/src/components/configuration/sections/endpoints/model.ts b/src/components/configuration/sections/endpoints/model.ts new file mode 100644 index 00000000..4c8b4b83 --- /dev/null +++ b/src/components/configuration/sections/endpoints/model.ts @@ -0,0 +1,159 @@ +/** + * Pure helpers of the custom endpoint editor (WS10): what an endpoint's list + * row shows (host, model count) and what keeps it from being saved or served + * (CE-3, CE-4). LibreChat loads a custom endpoint only with a name, an API + * key, a base URL and a model list (`loadCustomEndpointsConfig`), and its + * schema requires at least one default model even when the list is fetched. + */ +import { EModelEndpoint } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { isConfigRecord } from '@/utils'; + +const RESERVED_NAMES: ReadonlySet = new Set(Object.values(EModelEndpoint)); +const USER_PROVIDED = 'user_provided'; +const ENV_PLACEHOLDER_RE = /\$\{[A-Za-z0-9_]+\}/; + +function hasText(value: t.ConfigValue): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +/** An endpoint's `models` object, or an empty one. */ +function modelsOf(entry: t.ConfigValue): t.ConfigRecord { + if (!isConfigRecord(entry)) return {}; + return isConfigRecord(entry.models) ? entry.models : {}; +} + +/** Names of the default models (a model is a name, or `{ name, description }`). */ +export function modelNames(entry: t.ConfigValue): string[] { + const list = modelsOf(entry).default; + if (!Array.isArray(list)) return []; + return list.map((item) => { + if (typeof item === 'string') return item; + if (isConfigRecord(item) && typeof item.name === 'string') return item.name; + return ''; + }); +} + +/** Whether the model list is also fetched from the provider's API. */ +export function fetchesModels(entry: t.ConfigValue): boolean { + return modelsOf(entry).fetch === true; +} + +/** + * Host (and path) a list row shows for a base URL: `api.moonshot.ai/v1`, the + * scheme and trailing slashes dropped. Environment references stay as written. + */ +export function endpointHost(baseURL: t.ConfigValue): string | null { + if (!hasText(baseURL)) return null; + return ( + baseURL + .trim() + .replace(/^https?:\/\//i, '') + .replace(/\/+$/, '') || null + ); +} + +/** Whether a base URL can be used as written: an http(s) URL, an environment reference, or `user_provided`. */ +export function isUsableBaseURL(baseURL: string): boolean { + const text = baseURL.trim(); + if (text === USER_PROVIDED || ENV_PLACEHOLDER_RE.test(text)) return true; + try { + const url = new URL(text); + return (url.protocol === 'http:' || url.protocol === 'https:') && url.host !== ''; + } catch { + return false; + } +} + +/** Names LibreChat reserves for its built-in endpoints (`openAI`, `anthropic`, …). */ +function isReservedEndpointName(name: string): boolean { + return RESERVED_NAMES.has(name); +} + +function nameIssues(name: t.ConfigValue, otherNames: readonly string[]): t.EndpointIssue[] { + if (!hasText(name)) { + return [{ field: 'name', code: 'nameRequired', severity: 'error', unserved: true }]; + } + const trimmed = name.trim(); + if (trimmed !== name) return [{ field: 'name', code: 'nameWhitespace', severity: 'error' }]; + if (isReservedEndpointName(trimmed)) { + return [{ field: 'name', code: 'nameReserved', severity: 'error' }]; + } + if (otherNames.includes(name)) { + return [{ field: 'name', code: 'nameTaken', severity: 'error', params: { name } }]; + } + const lower = trimmed.toLowerCase(); + const similar = otherNames.find((other) => other.trim().toLowerCase() === lower); + if (similar !== undefined) { + return [{ field: 'name', code: 'nameSimilar', severity: 'warning', params: { name: similar } }]; + } + return []; +} + +function modelIssues(entry: t.ConfigValue): t.EndpointIssue[] { + const names = modelNames(entry); + if (names.length === 0) { + return [ + { + field: 'models.default', + code: 'modelsRequired', + severity: 'error', + unserved: !fetchesModels(entry), + }, + ]; + } + const issues: t.EndpointIssue[] = []; + if (names.some((name) => name.trim() === '')) { + issues.push({ field: 'models.default', code: 'modelBlank', severity: 'error' }); + } + const seen = new Set(); + const repeated = new Set(); + for (const name of names) { + if (seen.has(name)) repeated.add(name); + seen.add(name); + } + if (repeated.size > 0) { + issues.push({ + field: 'models.default', + code: 'modelDuplicate', + severity: 'warning', + params: { models: [...repeated].join(', ') }, + }); + } + return issues; +} + +/** + * Problems of one custom endpoint, by field. A literal key librechat.yaml + * sets is redacted on read, so a key is required only of endpoints the + * target adds. A renamed endpoint whose key is stored encrypted needs the key + * again: LibreChat ties the stored key to the old name (CE-1). + */ +export function validateEndpoint(input: t.EndpointValidationInput): t.EndpointIssue[] { + const { entry, otherNames, fromLowerLayer, storedSecret, loadedName } = input; + if (!isConfigRecord(entry)) return []; + const issues: t.EndpointIssue[] = fromLowerLayer ? [] : nameIssues(entry.name, otherNames); + const renamed = loadedName !== undefined && loadedName !== entry.name; + if (storedSecret && renamed && !hasText(entry.apiKey)) { + issues.push({ field: 'apiKey', code: 'renameNeedsKey', severity: 'error' }); + } else if (!fromLowerLayer && !storedSecret && !hasText(entry.apiKey)) { + issues.push({ field: 'apiKey', code: 'apiKeyRequired', severity: 'error', unserved: true }); + } + if (!hasText(entry.baseURL)) { + issues.push({ field: 'baseURL', code: 'baseUrlRequired', severity: 'error', unserved: true }); + } else if (!isUsableBaseURL(entry.baseURL)) { + issues.push({ field: 'baseURL', code: 'baseUrlInvalid', severity: 'warning' }); + } + issues.push(...modelIssues(entry)); + return issues; +} + +/** Whether LibreChat would skip the endpoint at runtime (missing name, key, URL or models). */ +export function isUnserved(issues: readonly t.EndpointIssue[]): boolean { + return issues.some((issue) => issue.unserved === true); +} + +/** Whether any issue blocks saving. */ +export function hasBlockingIssue(issues: readonly t.EndpointIssue[]): boolean { + return issues.some((issue) => issue.severity === 'error'); +} diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts new file mode 100644 index 00000000..2b69222a --- /dev/null +++ b/src/constants/endpoints.ts @@ -0,0 +1,150 @@ +import type * as t from '@/types'; + +/** + * Starting points for a new custom endpoint (WS10 CE-10): the base URL, the + * environment variable the key is usually read from, a few models and the + * settings each provider is known to need (OpenRouter and Perplexity reject + * `stop`, Mistral rejects the OpenAI penalty and `user` parameters). Model + * lists change often: they are examples to edit, and providers that list + * their models over the API fetch them. + */ +export const ENDPOINT_TEMPLATES: readonly t.EndpointTemplate[] = [ + { + id: 'openrouter', + name: 'OpenRouter', + descriptionKey: 'com_config_ce_template_openrouter', + entry: { + name: 'OpenRouter', + apiKey: '${OPENROUTER_KEY}', + baseURL: 'https://openrouter.ai/api/v1', + models: { default: ['openai/gpt-4o-mini'], fetch: true }, + titleConvo: true, + titleModel: 'openai/gpt-4o-mini', + dropParams: ['stop'], + modelDisplayLabel: 'OpenRouter', + }, + }, + { + id: 'groq', + name: 'Groq', + descriptionKey: 'com_config_ce_template_groq', + entry: { + name: 'Groq', + apiKey: '${GROQ_API_KEY}', + baseURL: 'https://api.groq.com/openai/v1/', + models: { default: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'], fetch: true }, + titleConvo: true, + titleModel: 'llama-3.1-8b-instant', + modelDisplayLabel: 'Groq', + }, + }, + { + id: 'mistral', + name: 'Mistral', + descriptionKey: 'com_config_ce_template_mistral', + entry: { + name: 'Mistral', + apiKey: '${MISTRAL_API_KEY}', + baseURL: 'https://api.mistral.ai/v1', + models: { default: ['mistral-large-latest', 'mistral-small-latest'], fetch: true }, + titleConvo: true, + titleModel: 'mistral-small-latest', + dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'], + modelDisplayLabel: 'Mistral', + }, + }, + { + id: 'deepseek', + name: 'DeepSeek', + descriptionKey: 'com_config_ce_template_deepseek', + entry: { + name: 'DeepSeek', + apiKey: '${DEEPSEEK_API_KEY}', + baseURL: 'https://api.deepseek.com/v1', + models: { default: ['deepseek-chat', 'deepseek-reasoner'], fetch: false }, + titleConvo: true, + titleModel: 'deepseek-chat', + modelDisplayLabel: 'DeepSeek', + }, + }, + { + id: 'xai', + name: 'xAI', + descriptionKey: 'com_config_ce_template_xai', + entry: { + name: 'xAI', + apiKey: '${XAI_API_KEY}', + baseURL: 'https://api.x.ai/v1', + models: { default: ['grok-3', 'grok-3-mini'], fetch: true }, + titleConvo: true, + titleModel: 'grok-3-mini', + modelDisplayLabel: 'Grok', + }, + }, + { + id: 'together', + name: 'Together AI', + descriptionKey: 'com_config_ce_template_together', + entry: { + name: 'Together AI', + apiKey: '${TOGETHERAI_API_KEY}', + baseURL: 'https://api.together.xyz/v1', + models: { default: ['meta-llama/Llama-3.3-70B-Instruct-Turbo'], fetch: true }, + titleConvo: true, + titleModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + modelDisplayLabel: 'Together AI', + }, + }, + { + id: 'perplexity', + name: 'Perplexity', + descriptionKey: 'com_config_ce_template_perplexity', + entry: { + name: 'Perplexity', + apiKey: '${PERPLEXITY_API_KEY}', + baseURL: 'https://api.perplexity.ai/', + models: { default: ['sonar', 'sonar-pro'], fetch: false }, + titleConvo: true, + titleModel: 'sonar', + dropParams: ['stop', 'frequency_penalty'], + modelDisplayLabel: 'Perplexity', + }, + }, + { + id: 'fireworks', + name: 'Fireworks', + descriptionKey: 'com_config_ce_template_fireworks', + entry: { + name: 'Fireworks', + apiKey: '${FIREWORKS_API_KEY}', + baseURL: 'https://api.fireworks.ai/inference/v1', + models: { default: ['accounts/fireworks/models/llama-v3p3-70b-instruct'], fetch: true }, + titleConvo: true, + titleModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct', + dropParams: ['user'], + modelDisplayLabel: 'Fireworks', + }, + }, + { + id: 'ollama', + name: 'Ollama', + descriptionKey: 'com_config_ce_template_ollama', + entry: { + name: 'Ollama', + apiKey: 'ollama', + baseURL: 'http://host.docker.internal:11434/v1/', + models: { default: ['llama3.2'], fetch: true }, + titleConvo: true, + titleModel: 'current_model', + modelDisplayLabel: 'Ollama', + }, + }, +]; + +/** A blank OpenAI-compatible endpoint. */ +export const BLANK_ENDPOINT_TEMPLATE: t.EndpointTemplate = { + id: 'blank', + name: '', + descriptionKey: 'com_config_ce_template_blank', + entry: {}, +}; diff --git a/src/constants/index.ts b/src/constants/index.ts index 4f642a75..c57b58e0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -5,3 +5,4 @@ export * from './scope'; export * from './config'; export * from './collections'; export * from './providers'; +export * from './endpoints'; diff --git a/src/contexts/CollectionEditsContext.ts b/src/contexts/CollectionEditsContext.ts new file mode 100644 index 00000000..e8aef13d --- /dev/null +++ b/src/contexts/CollectionEditsContext.ts @@ -0,0 +1,13 @@ +import { createContext } from 'react'; +import type * as t from '@/types'; + +const NO_COLLECTION_EDITS: t.CollectionEdits = {}; + +/** + * The page's pending keyed-collection edits (WS1 `collectionEdits`): which + * loaded entry each pending item came from, and the loaded entries a pending + * edit took out. Collection editors read it to show pending deletions (with + * Undo) and renames from the edit state itself, so they survive Discard → + * Undo and edits made from outside the editor (WS10). + */ +export const CollectionEditsContext = createContext(NO_COLLECTION_EDITS); diff --git a/src/contexts/EntryFieldChipsContext.ts b/src/contexts/EntryFieldChipsContext.ts new file mode 100644 index 00000000..4096c48f --- /dev/null +++ b/src/contexts/EntryFieldChipsContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; +import type * as t from '@/types'; + +/** + * Extra chips for the field rows of a collection entry (Edited with Undo, + * an admin override with Revert), provided by a collection editor such as + * the custom endpoint editor (WS10). Null: entry rows show no such chips. + */ +export const EntryFieldChipsContext = createContext(null); diff --git a/src/hooks/config/endpoints.ts b/src/hooks/config/endpoints.ts new file mode 100644 index 00000000..242c6eec --- /dev/null +++ b/src/hooks/config/endpoints.ts @@ -0,0 +1,131 @@ +import { useContext, useMemo } from 'react'; +import type * as t from '@/types'; +import { + getEntryKey, + isConfigRecord, + getValueAtPath, + hasHiddenSecret, + isConfigValueEqual, + getKeyedCollection, + overriddenEntryKeys, +} from '@/utils'; +import { + endpointHost, + fetchesModels, + modelNames, + validateEndpoint, +} from '@/components/configuration/sections/endpoints/model'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { getEntryTitle } from '@/components/configuration/fields/entries'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { useLocalize } from '../useLocalize'; +import { useListKeys } from './keys'; + +const NAME_KEY = 'name'; + +function nameOf(item: t.ConfigValue): string | null { + return getEntryKey(item, NAME_KEY) ?? null; +} + +function byName(list: t.ConfigValue): Map { + const map = new Map(); + if (!Array.isArray(list)) return map; + for (const entry of list) { + const name = getEntryKey(entry, NAME_KEY); + if (name !== undefined && isConfigRecord(entry)) map.set(name, entry); + } + return map; +} + +/** + * The custom endpoint list of the open target as the endpoint editor shows + * it (WS10): per row where the endpoint comes from (librechat.yaml, the admin + * panel, Base or the profile), whether the target overrides it, whether it is + * new or edited, and what keeps it from being saved or served; plus the + * saved endpoints a pending edit removes. Renames and removals are read from + * the page's pending collection edits, so they survive Discard → Undo. + */ +export function useEndpointRows({ path, items }: t.EndpointRowsOptions): t.EndpointRows { + const localize = useLocalize(); + const layers = useContext(ConfigValuesContext)?.layers ?? null; + const edit = useContext(CollectionEditsContext)[path]; + const keys = useListKeys({ items, identity: nameOf, prefix: 'endpoint' }); + + const context = useMemo(() => { + const collection = getKeyedCollection(path); + const lowerRoot = layers?.scope ? layers.base : (layers?.yamlEffective ?? null); + const storedRoot = layers?.scope ? layers.scope.overrides : (layers?.baseOverrides ?? null); + const lowerList = getValueAtPath(lowerRoot, path); + const storedList = getValueAtPath(storedRoot, path); + return { + collection, + lower: byName(lowerList), + stored: byName(storedList), + saved: byName(getValueAtPath(layers?.effective ?? null, path)), + overridden: collection + ? overriddenEntryKeys(collection, storedList, lowerList) + : new Set(), + isScope: layers?.scope != null, + }; + }, [layers, path]); + + return useMemo((): t.EndpointRows => { + const { collection, lower, stored, saved, overridden, isScope } = context; + const names = items.map((item) => getEntryKey(item, NAME_KEY) ?? ''); + const rows = items.map((item, index): t.EndpointRow => { + const value = isConfigRecord(item) ? item : {}; + const identity = getEntryKey(item, NAME_KEY); + const origin = edit ? edit.origins[index] : undefined; + const isNew = edit != null && origin === null; + const loadedName = isNew ? undefined : (origin?.identity ?? identity); + const fromLower = loadedName !== undefined && lower.has(loadedName); + const storedEntry = loadedName === undefined ? undefined : stored.get(loadedName); + const storedSecret = collection + ? hasHiddenSecret(storedEntry, collection) || hasHiddenSecret(value, collection) + : false; + const savedEntry = loadedName === undefined ? undefined : saved.get(loadedName); + let entryOrigin: t.EndpointOrigin = isScope ? 'scope' : 'admin'; + if (fromLower) entryOrigin = isScope ? 'inherited' : 'yaml'; + return { + key: keys[index], + index, + value, + title: getEntryTitle(item, index, localize), + identity, + loadedName, + origin: entryOrigin, + overridden: fromLower && loadedName !== undefined && overridden.has(loadedName), + isNew, + edited: !isNew && savedEntry !== undefined && !isConfigValueEqual(savedEntry, item), + saved: savedEntry, + host: endpointHost(value.baseURL), + modelCount: modelNames(value).length, + fetchesModels: fetchesModels(value), + storedSecret, + issues: validateEndpoint({ + entry: value, + otherNames: names.filter((_, i) => i !== index), + fromLowerLayer: fromLower, + storedSecret, + loadedName, + }), + }; + }); + const removals = (edit?.detached ?? []).map((detached): t.EndpointRemoval => { + const at = edit?.baseline.findIndex( + (entry) => getEntryKey(entry, NAME_KEY) === detached.origin.identity, + ); + return { + key: `removed-${detached.origin.identity}`, + title: getEntryTitle(detached.value, at ?? 0, localize), + value: detached.value, + index: at === undefined || at < 0 ? items.length : at, + }; + }); + return { + rows, + removals, + lowerEntry: (name) => lower.get(name), + }; + }, [context, items, edit, keys, localize]); +} diff --git a/src/hooks/config/index.ts b/src/hooks/config/index.ts index 22d2aab5..483f4f5a 100644 --- a/src/hooks/config/index.ts +++ b/src/hooks/config/index.ts @@ -2,6 +2,7 @@ export * from './baseline'; export * from './counts'; export * from './draft'; export * from './edits'; +export * from './endpoints'; export * from './expand'; export * from './field'; export * from './guard'; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a1e1ada8..1c8dc00e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -76,6 +76,205 @@ "com_config_endpoint_name_reserved": "This name belongs to a built-in LibreChat endpoint; choose another name", "com_config_endpoint_name_required": "Endpoint name is required", "com_config_endpoint_required_missing": "Still needed: {{fields}}", + "com_config_ce_action_delete": "Delete endpoint", + "com_config_ce_action_edit": "Edit", + "com_config_ce_action_revert": "Revert to librechat.yaml", + "com_config_ce_action_view": "View", + "com_config_ce_actions_a11y": "Actions for {{name}}", + "com_config_ce_add_setting": "Add a setting…", + "com_config_ce_add_setting_a11y": "Add a {{section}} setting", + "com_config_ce_back": "Back", + "com_config_ce_create": "Create endpoint", + "com_config_ce_create_form_desc": "Fill in what LibreChat needs to serve it. Everything else can be set after it is created.", + "com_config_ce_create_inherited": "{{scope}} already gets \"{{name}}\" from Base. To change it for {{scope}}, override it instead of creating a second endpoint with the same name.", + "com_config_ce_create_more": "Titles, headers, parameters and pricing can be set in the editor once the endpoint is created. Nothing is saved until you review and save your changes.", + "com_config_ce_create_pick_desc": "Start from a provider, or from a blank OpenAI-compatible endpoint.", + "com_config_ce_current_model": "Current conversation model", + "com_config_ce_current_model_note": "Uses whichever model the conversation is on.", + "com_config_ce_delete_locked": "Defined in librechat.yaml: it can be changed here, but only removed from librechat.yaml", + "com_config_ce_delete_locked_inherited": "Comes from Base: it can be overridden in this profile, not removed", + "com_config_ce_discard": "Discard", + "com_config_ce_discard_confirm": "Discard this new endpoint?", + "com_config_ce_done": "Done", + "com_config_ce_drawer_desc": "Custom endpoint", + "com_config_ce_drawer_staged": "Changes are kept until you review and save", + "com_config_ce_empty_scope": "No custom endpoints yet. Create one to add it for this profile only.", + "com_config_ce_field_override_desc": "Set in the admin panel; replaces the librechat.yaml value", + "com_config_ce_field_override_scope_desc": "Set in this profile; replaces the value it inherits from Base", + "com_config_ce_filter_a11y": "Filter endpoints", + "com_config_ce_filter_empty": "No endpoint matches \"{{query}}\".", + "com_config_ce_filter_placeholder": "Filter by name, URL or model", + "com_config_ce_issue_api_key_required": "Enter an API key, an environment variable such as ${PROVIDER_API_KEY}, or user_provided to let each user enter their own.", + "com_config_ce_issue_base_url_invalid": "This doesn't look like a URL. Use https://…, an environment variable such as ${PROVIDER_URL}, or user_provided.", + "com_config_ce_issue_base_url_required": "Enter the base URL of the provider's API.", + "com_config_ce_issue_model_blank": "Remove the blank model, or give it a name.", + "com_config_ce_issue_model_duplicate": "Listed more than once: {{models}}. Users see each copy in the model menu.", + "com_config_ce_issue_models_required": "Add at least one model. LibreChat requires a default list even when it fetches models, and uses it if fetching fails.", + "com_config_ce_issue_name_required": "Enter a name.", + "com_config_ce_issue_name_reserved": "This name belongs to a built-in LibreChat endpoint. Choose another name.", + "com_config_ce_issue_name_similar": "Another endpoint is named \"{{name}}\". Names that differ only in letter case are easy to confuse.", + "com_config_ce_issue_name_taken": "Another endpoint is already named \"{{name}}\".", + "com_config_ce_issue_name_whitespace": "Remove the space at the start or end of the name.", + "com_config_ce_issue_rename_needs_key": "Enter the API key again: the stored key belongs to the old name and can't move to the new one.", + "com_config_ce_keep_editing": "Keep editing", + "com_config_ce_key_hidden": "Not shown", + "com_config_ce_key_hidden_note": "librechat.yaml may set this key as plain text, which LibreChat never sends back. Replace it to store a new key here.", + "com_config_ce_key_placeholder": "New key, ${ENV_VAR} or user_provided", + "com_config_ce_key_replace": "Replace", + "com_config_ce_list_label": "Custom endpoints", + "com_config_ce_model_default": "Default (the conversation's model)", + "com_config_ce_model_placeholder": "Model id or current_model", + "com_config_ce_models_count_one": "{{count}} model", + "com_config_ce_models_count_other": "{{count}} models", + "com_config_ce_models_fetched": "also fetched", + "com_config_ce_name_locked": "Defined in librechat.yaml, so it can't be renamed here.", + "com_config_ce_name_locked_inherited": "Comes from Base, so it can't be renamed in this profile.", + "com_config_ce_name_placeholder": "Shown in the model menu, e.g. OpenRouter", + "com_config_ce_no_base_url": "No base URL", + "com_config_ce_none_set_in_scope": "{{scope}} doesn't change any custom endpoint.", + "com_config_ce_notice_inherited": "Inherited from Base. Users in {{scope}} get it as Base defines it.", + "com_config_ce_notice_inherited_override": "{{scope}} overrides this Base endpoint. Only the settings you change here are saved for {{scope}}.", + "com_config_ce_notice_yaml": "Defined in librechat.yaml. Changes here override its settings; it can't be renamed or deleted here.", + "com_config_ce_notice_yaml_overridden": "Defined in librechat.yaml, with settings overridden in the admin panel. It can't be renamed or deleted here.", + "com_config_ce_override_for": "Override for {{scope}}", + "com_config_ce_override_named": "Override {{name}}", + "com_config_ce_params_default": "Default (OpenAI-compatible)", + "com_config_ce_params_openai_compatible": "OpenAI-compatible", + "com_config_ce_ref_default_endpoint": "Default (this endpoint)", + "com_config_ce_remove_scope_overrides": "Remove {{scope}} overrides", + "com_config_ce_section_behaviour": "Behaviour", + "com_config_ce_section_behaviour_desc": "Conversation titles, streaming and tool output.", + "com_config_ce_section_connection": "Connection", + "com_config_ce_section_connection_desc": "Where requests go and how they authenticate.", + "com_config_ce_section_errors_one": "{{count}} problem", + "com_config_ce_section_errors_other": "{{count}} problems", + "com_config_ce_section_labels": "Labels & reasoning", + "com_config_ce_section_labels_desc": "Progress labels while the model works, and how reasoning is read from responses.", + "com_config_ce_section_models": "Models", + "com_config_ce_section_models_desc": "The models users can pick for this endpoint.", + "com_config_ce_section_nothing_set": "Nothing set.", + "com_config_ce_section_other": "Other settings", + "com_config_ce_section_parameters": "Parameters", + "com_config_ce_section_parameters_desc": "The parameter panel users see, and defaults for its parameters.", + "com_config_ce_section_pricing": "Token pricing", + "com_config_ce_section_pricing_desc": "Context window and per-million-token rates used for balances and usage.", + "com_config_ce_section_request": "Request shaping", + "com_config_ce_section_request_desc": "Headers and parameters added to or removed from every request.", + "com_config_ce_sections_nav": "Endpoint settings", + "com_config_ce_source_admin": "Added in admin", + "com_config_ce_source_admin_desc": "Created in the admin panel; librechat.yaml doesn't define it", + "com_config_ce_source_inherited": "Inherited from Base", + "com_config_ce_source_inherited_desc": "Defined in the base configuration", + "com_config_ce_source_inherited_overrides": "Base + {{scope}}", + "com_config_ce_source_inherited_overrides_desc": "Defined in the base configuration, with settings overridden for {{scope}}", + "com_config_ce_source_scope": "Added in {{scope}}", + "com_config_ce_source_scope_desc": "Only {{scope}} has this endpoint", + "com_config_ce_source_yaml": "librechat.yaml", + "com_config_ce_source_yaml_desc": "Defined in librechat.yaml", + "com_config_ce_source_yaml_overrides": "librechat.yaml + overrides", + "com_config_ce_source_yaml_overrides_desc": "Defined in librechat.yaml, with settings overridden in the admin panel", + "com_config_ce_state_new": "New", + "com_config_ce_state_new_desc": "Added since the last save", + "com_config_ce_status_invalid": "Needs a fix", + "com_config_ce_status_unserved": "Incomplete – not served", + "com_config_ce_template_blank": "Any OpenAI-compatible API: enter its URL, key and models yourself.", + "com_config_ce_template_blank_name": "Blank endpoint", + "com_config_ce_template_deepseek": "DeepSeek chat and reasoner models.", + "com_config_ce_template_fireworks": "Open models hosted by Fireworks AI.", + "com_config_ce_template_groq": "Fast inference for open models on Groq.", + "com_config_ce_template_heading": "Start from", + "com_config_ce_template_mistral": "Mistral's models; drops the parameters Mistral rejects.", + "com_config_ce_template_ollama": "Models running on your own Ollama server.", + "com_config_ce_template_openrouter": "Hundreds of models through one key; the model list is fetched.", + "com_config_ce_template_perplexity": "Sonar search models; drops the parameters Perplexity rejects.", + "com_config_ce_template_together": "Open models hosted by Together AI.", + "com_config_ce_template_xai": "Grok models from xAI.", + "com_config_ce_param_add_builtin": "Override a built-in parameter…", + "com_config_ce_param_add_custom": "Add a custom parameter", + "com_config_ce_param_builtin_default": "Built-in default: {{value}}", + "com_config_ce_param_builtin_note": "LibreChat shows its own translated text ({{code}}).", + "com_config_ce_param_builtin_text": "Built-in text", + "com_config_ce_param_component_auto": "Default for the type", + "com_config_ce_param_component_checkbox": "Checkbox", + "com_config_ce_param_component_combobox": "Searchable list", + "com_config_ce_param_component_dropdown": "Dropdown", + "com_config_ce_param_component_input": "Text input", + "com_config_ce_param_component_slider": "Slider", + "com_config_ce_param_component_switch": "Switch", + "com_config_ce_param_component_tags": "Tags", + "com_config_ce_param_component_textarea": "Text area", + "com_config_ce_param_custom_text": "Use custom text", + "com_config_ce_param_customize": "Customize every option", + "com_config_ce_param_default_summary": "default {{value}}", + "com_config_ce_param_key_locked": "Defined in librechat.yaml, so its key can't change here.", + "com_config_ce_param_no_default": "No default", + "com_config_ce_param_override_note": "Changes the default of a built-in parameter; everything else stays as LibreChat defines it.", + "com_config_ce_param_pick_type": "Pick a type first", + "com_config_ce_param_type_array": "List of values", + "com_config_ce_param_type_boolean": "On / off", + "com_config_ce_param_type_enum": "One of a set of options", + "com_config_ce_param_type_number": "Number", + "com_config_ce_param_type_string": "Text", + "com_config_ce_param_type_unset": "Not set", + "com_config_ce_param_untitled": "Parameter {{n}}", + "com_config_ce_params_desc": "Parameters users can adjust for this endpoint, and their defaults.", + "com_config_ce_params_empty": "Users get the parameters of the parameter set above, with its defaults.", + "com_config_ce_drop_params_none": "None", + "com_config_ce_drop_params_placeholder": "Add a parameter, e.g. stop", + "com_config_ce_placeholder_copied": "Copied {{value}}. Paste it into a header value.", + "com_config_ce_placeholder_insert": "Insert at the cursor of the header value you are editing", + "com_config_ce_placeholders": "Placeholders:", + "com_config_ce_revert": "Revert", + "com_config_ce_pricing_add": "Add model", + "com_config_ce_pricing_add_placeholder": "Model to price, e.g. gpt-4o-mini", + "com_config_ce_pricing_cache_read": "Cache read", + "com_config_ce_pricing_cache_write": "Cache write", + "com_config_ce_pricing_completion": "Output", + "com_config_ce_pricing_context": "Context (tokens)", + "com_config_ce_pricing_desc": "Rates in USD per million tokens. Models without a row use LibreChat's own rates, if it knows the model.", + "com_config_ce_pricing_empty": "No model has its own pricing.", + "com_config_ce_pricing_missing": "Enter input, output and context.", + "com_config_ce_pricing_model": "Model", + "com_config_ce_pricing_prompt": "Input", + "com_config_ce_pricing_rates": "Model rates", + "com_config_ce_provider_anthropic": "Use the Anthropic Messages API", + "com_config_desc_endpoints_custom_activityPhaseEndpoint": "Endpoint whose credentials run the phase summary model.", + "com_config_desc_endpoints_custom_activityPhaseModel": "Model that writes phase summaries. Defaults to the activity model.", + "com_config_desc_endpoints_custom_activityPhasePrompt": "Replaces the instructions used to write phase summaries.", + "com_config_desc_endpoints_custom_customParams_includeReasoningContent": "Send the model's reasoning back within a run's tool-call turns, as models such as Kimi and Xiaomi MiMo require.", + "com_config_desc_endpoints_custom_customParams_includeReasoningHistory": "Also rebuild reasoning from earlier turns of the conversation. Implies Include reasoning content.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_columnSpan": "How many columns of the parameter panel the control spans.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_columns": "How many columns the options are laid out in.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_component": "The control users see in the parameter panel.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_default": "Value used until a user changes it.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_description": "Help shown with the parameter.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_enumMappings": "Value sent to the provider for each option.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_includeInput": "Show a number input next to the slider.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_key": "The parameter's name in requests, for example temperature. Using a built-in parameter's name changes that parameter.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_label": "Name shown in the parameter panel.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_maxTags": "Most values allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_maxText": "Longest text allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_minTags": "Fewest values allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_minText": "Shortest text allowed.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_optionType": "Where the value is kept: with the conversation, with the model, or as a custom option.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_options": "The choices users can pick from.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_placeholder": "Text shown in the empty input.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_range": "Lowest and highest value, and the step between values.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_searchPlaceholder": "Text shown in the search box.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_selectPlaceholder": "Text shown before an option is picked.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_showDefault": "Show the default value next to the label.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_showLabel": "Show the label above the control.", + "com_config_desc_endpoints_custom_customParams_paramDefinitions_type": "Kind of value: a number, on/off, text, one of a set of options, or a list.", + "com_config_desc_endpoints_custom_customParams_reasoningFormat": "How the reasoning effort is sent to the provider: as reasoning_effort, as a reasoning object, or not at all.", + "com_config_desc_endpoints_custom_customParams_reasoningKey": "The response field the model's reasoning text is read from.", + "com_config_desc_endpoints_custom_directEndpoint": "Send requests to the base URL exactly as written, without adding /chat/completions.", + "com_config_desc_endpoints_custom_reasoningLabelEndpoint": "Endpoint whose credentials run the reasoning label model.", + "com_config_desc_endpoints_custom_reasoningLabelMinChars": "Characters of reasoning before the first label is written.", + "com_config_desc_endpoints_custom_reasoningLabelPrompt": "Replaces the instructions used to write reasoning labels.", + "com_config_desc_endpoints_custom_reasoningLabelUpdateChars": "New reasoning characters needed before the label is rewritten.", + "com_config_desc_endpoints_custom_reasoningLabelUpdateIntervalMs": "Shortest time between two label updates.", + "com_config_desc_endpoints_custom_titleMessageRole": "Role of the message that carries the title instructions.", + "com_config_issue_name_whitespace": "starts or ends with a space", "com_config_create_mcp_server": "Create MCP server", "com_config_server_name": "Server name", "com_config_server_name_required": "Server name is required", diff --git a/src/types/endpoints.ts b/src/types/endpoints.ts new file mode 100644 index 00000000..12dc6f51 --- /dev/null +++ b/src/types/endpoints.ts @@ -0,0 +1,303 @@ +import type { SettingDefinition } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import type { ConfigRecord, ConfigValue, SchemaField } from './config'; + +/** + * Where a custom endpoint comes from, seen from the edit target (WS10): in + * Base, librechat.yaml (`yaml`) or the admin panel (`admin`); in a profile, + * Base (`inherited`) or the profile itself (`scope`). + */ +export type EndpointOrigin = 'yaml' | 'admin' | 'inherited' | 'scope'; + +/** What is wrong with a custom endpoint, by field (WS10 CE-3/CE-4). */ +export type EndpointIssueCode = + | 'nameRequired' + | 'nameWhitespace' + | 'nameTaken' + | 'nameSimilar' + | 'nameReserved' + | 'apiKeyRequired' + | 'baseUrlRequired' + | 'baseUrlInvalid' + | 'modelsRequired' + | 'modelBlank' + | 'modelDuplicate' + | 'renameNeedsKey'; + +export interface EndpointIssue { + /** Field inside the entry (`name`, `models.default`). */ + field: string; + code: EndpointIssueCode; + /** `error` blocks the save; `warning` is shown but saves. */ + severity: 'error' | 'warning'; + /** LibreChat skips the endpoint at runtime while this is unresolved ("Incomplete – not served"). */ + unserved?: boolean; + params?: Record; +} + +/** Inputs of `validateEndpoint`. */ +export interface EndpointValidationInput { + entry: ConfigValue; + /** Names of the other endpoints in the list (pending), to catch duplicates. */ + otherNames: readonly string[]; + /** The layer beneath defines this endpoint (its literal key is redacted on read). */ + fromLowerLayer: boolean; + /** The target stores an encrypted key for it, readable only as a preview. */ + storedSecret: boolean; + /** The name the entry was loaded with, when it was renamed since. */ + loadedName?: string; +} + +/** One custom endpoint as the list shows it (WS10 CE-7). */ +export interface EndpointRow { + /** Stable React key across edits, renames and removals. */ + key: string; + /** Position in the pending list. */ + index: number; + value: ConfigRecord; + title: string; + /** The entry's name, when it has one. */ + identity?: string; + /** The name it was loaded with (differs after a pending rename); undefined for a new entry. */ + loadedName?: string; + origin: EndpointOrigin; + /** The target stores its own override of an endpoint the layer beneath defines. */ + overridden: boolean; + /** Added since the page loaded (not saved yet). */ + isNew: boolean; + /** Differs from its saved value. */ + edited: boolean; + /** The endpoint as saved (what Undo goes back to); undefined for a new one. */ + saved?: ConfigRecord; + host: string | null; + modelCount: number; + fetchesModels: boolean; + /** An encrypted key is stored for it; the read shows only its preview. */ + storedSecret: boolean; + issues: EndpointIssue[]; +} + +/** A saved endpoint a pending edit removes; Undo puts it back at `index`. */ +export interface EndpointRemoval { + key: string; + title: string; + value: ConfigValue; + index: number; +} + +/** The custom endpoint list of the open target, with pending edits (WS10). */ +export interface EndpointRows { + rows: EndpointRow[]; + removals: EndpointRemoval[]; + /** The value of an endpoint in the layer beneath (librechat.yaml, or Base in a profile). */ + lowerEntry: (name: string) => ConfigRecord | undefined; +} + +export interface EndpointRowsOptions { + /** `endpoints.custom` */ + path: string; + items: ConfigValue[]; +} + +/** A field of the endpoint editor: its key inside the entry, which may be nested (`models.default`). */ +export interface EndpointFieldDef { + key: string; + /** Shown only when set, or once added from "Add setting". */ + optional?: boolean; +} + +/** One section of the endpoint editor (WS10 CE-7). */ +export interface EndpointSectionDef { + id: string; + labelKey: string; + descriptionKey?: string; + fields: EndpointFieldDef[]; + /** Unknown and remaining schema keys land here. */ + catchAll?: boolean; +} + +export interface EndpointDrawerProps { + open: boolean; + onClose: () => void; + title: string; + /** Chips beside the title (source, state). */ + badges?: ReactNode; + /** Short line under the title (base URL host, origin). */ + subtitle?: ReactNode; + /** Shown above the body, below the header (banners). */ + notice?: ReactNode; + footer: ReactNode; + children: ReactNode; + /** Close requests (Esc, overlay, ×) go here instead, e.g. to confirm discarding a draft. */ + onRequestClose?: () => void; + bodyRef?: React.Ref; + /** The element focus goes back to on close (there is no Radix trigger). */ + returnFocus?: () => HTMLElement | null; +} + +export interface EndpointEditorProps { + /** Data path of the entry (`endpoints.custom.3`), or the draft's while creating. */ + path: string; + fields: SchemaField[]; + value: ConfigRecord; + /** Writes one key of the entry; `undefined` removes it. */ + onFieldChange: (key: string, value: ConfigValue) => void; + issues: EndpointIssue[]; + origin: EndpointOrigin; + disabled?: boolean; + /** Names of the endpoints in the list, for the endpoint pickers. */ + endpointNames: readonly string[]; + storedSecret: boolean; + /** The endpoint as the layer beneath defines it (librechat.yaml, or Base in a profile). */ + lowerEntry?: ConfigRecord; + /** The endpoint as saved, which a field's Undo goes back to. */ + savedEntry?: ConfigRecord; + /** Default models of another custom endpoint, for model pickers that name it. */ + modelsOf?: (endpoint: string) => readonly string[]; + /** Only the essentials (creating): Connection and Models. */ + essentialsOnly?: boolean; + /** Field rows carry Edited/Undo and override/Revert chips (saved endpoints). */ + showChips?: boolean; + /** Entry field to bring into view (`baseURL`, `customParams.paramDefinitions.0`). */ + revealField?: string; + /** Called with the full path once `revealField` is rendered, to scroll to and focus it. */ + onRevealed?: (path: string) => void; + editSessionId?: number; +} + +/** A starting point offered when creating a custom endpoint (WS10 CE-10). */ +export interface EndpointTemplate { + id: string; + /** Display name, also the suggested endpoint name. */ + name: string; + descriptionKey: string; + /** The entry the template starts from (name included). */ + entry: ConfigRecord; +} + +/** Props of the chips an editor row carries: unsaved change with Undo, admin override with Revert. */ +export interface EntryFieldChipsProps { + /** Data path of the field (`endpoints.custom.3.baseURL`). */ + path: string; + label: string; + /** The value shown in the control (the pending one). */ + value: ConfigValue; + /** Writes the field (Undo puts the saved value back). */ + onChange: (value: ConfigValue) => void; + /** Goes back to the value of the layer beneath (default: writes that value). */ + onRevert?: () => boolean | void; + disabled?: boolean; +} + +/** Props of the endpoint editor's field chips. */ +export interface FieldChipsProps extends EntryFieldChipsProps { + /** The field's saved value: the pending one differs from it while Edited. */ + saved: ConfigValue; + /** The endpoint comes from the layer beneath, so a value it sets can be an override. */ + showOverride: boolean; +} + +/** Renders the extra chips of an entry field row, provided by a collection editor (WS10). */ +export type EntryFieldChipsRenderer = (props: EntryFieldChipsProps) => ReactNode; + +/** Tone of a notice in the endpoint editor. */ +export type EndpointBannerTone = 'info' | 'warning' | 'danger' | 'neutral'; + +/** Props of the custom endpoint list (WS10 CE-7). */ +export interface EndpointListProps { + rows: EndpointRow[]; + removals: EndpointRemoval[]; + /** Read-only: rows open the editor for viewing; no revert or delete. */ + disabled?: boolean; + /** The row whose editor is open. */ + openKey?: string | null; + onOpen: (row: EndpointRow) => void; + /** A reveal (table of contents, search, review) asks for the row: open its editor, keeping any field target. */ + onReveal: (row: EndpointRow) => void; + onDelete: (row: EndpointRow) => void; + /** Reverts every override of the endpoint (to librechat.yaml, or to Base in a profile). */ + onRevert: (row: EndpointRow) => void; + onUndoRemoval: (removal: EndpointRemoval) => void; + /** Row ids are `${idPrefix}-${index}`, the ids the table of contents and deep links target. */ + idPrefix: string; +} + +/** Props of the create flow (WS10 CE-3, CE-10). */ +export interface EndpointCreatorProps { + open: boolean; + /** Schema fields of a custom endpoint entry. */ + fields: SchemaField[]; + /** Names of the endpoints in the list (pending), to keep the new name unique. */ + names: readonly string[]; + /** In a profile: names Base defines, which are overridden rather than created (CE-8). */ + inheritedNames: readonly string[]; + onCreate: (entry: ConfigRecord) => void; + /** Opens the inherited endpoint `name` to override it instead. */ + onOverride: (name: string) => void; + onClose: () => void; + /** Where focus goes when the flow closes without creating (the Create button). */ + returnFocus?: () => HTMLElement | null; +} + +/** Props of an endpoint's parameter definitions (WS10 CE-6). */ +export interface ParamDefinitionsRowProps { + /** Data path of the list (`endpoints.custom.3.customParams.paramDefinitions`). */ + path: string; + field: SchemaField; + value: ConfigValue; + /** Keys of the definitions the layer beneath defines: they keep their key and can't be removed. */ + lowerKeys: ReadonlySet; + /** The parameter set whose built-in parameters can be overridden (`custom`, `anthropic`, …). */ + paramSet?: string; + onChange: (value: ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; + chips?: ReactNode; +} + +/** Props of one parameter definition's fields (WS10 CE-6). */ +export interface ParamDefinitionEntryProps { + fields: SchemaField[]; + value: ConfigRecord; + /** Data path of the definition. */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; + builtIns: Map; + /** The layer beneath defines this definition: its key can't change. */ + keyLocked: boolean; +} + +/** Props of a list of names edited as tags (`dropParams`). */ +export interface TagListFieldProps { + id: string; + values: string[]; + onChange: (values: string[]) => void; + /** Called instead of emitting an empty list when the last tag is removed. */ + onClear: () => void; + disabled?: boolean; + 'aria-label'?: string; +} + +/** Props of an endpoint's token pricing table (WS10 CE-7). */ +export interface PricingRowProps { + /** Data path of `tokenConfig`. */ + path: string; + value: ConfigValue; + /** The endpoint's models, suggested when adding a row. */ + models: readonly string[]; + /** Models the layer beneath prices: their rows can change but not be removed. */ + lockedModels: ReadonlySet; + onChange: (value: ConfigValue) => void; + disabled?: boolean; + chips?: ReactNode; +} + +/** Props of one row of the endpoint list. */ +export interface EndpointListRowProps extends Pick< + EndpointListProps, + 'idPrefix' | 'disabled' | 'onOpen' | 'onReveal' | 'onDelete' | 'onRevert' +> { + row: EndpointRow; + isOpen: boolean; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5452a79c..f66b369f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export type * from './auth'; export type * from './changes'; export type * from './config'; export type * from './config-ui'; +export type * from './endpoints'; export type * from './controls'; export type * from './fields'; export type * from './grant'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 3e301c60..d978963f 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -104,6 +104,7 @@ const ISSUE_MESSAGES: Readonly> = { invalid_document: 'is not a valid configuration', invalid_union: 'is not a valid value', models_required: 'needs at least one model', + name_whitespace: 'starts or ends with a space', YAML_MANAGED: 'is managed in librechat.yaml; LibreChat ignores admin overrides for it until it re-processes them', PROCESS_MCP_FIELD: @@ -254,7 +255,9 @@ export function validateLoadableEndpoint( }); const models = isConfigRecord(entry.models) ? entry.models : {}; const hasModels = Array.isArray(models.default) && models.default.length > 0; + const paddedName = typeof entry.name === 'string' && entry.name.trim() !== entry.name; return [ + ...(paddedName && hasText(entry.name) ? [issue('name', 'name_whitespace')] : []), ...(hasText(entry.apiKey) || hasStoredKey ? [] : [issue('apiKey', 'required')]), ...(hasText(entry.baseURL) ? [] : [issue('baseURL', 'required')]), ...(hasModels ? [] : [issue('models.default', 'models_required')]), From 6c84a38f4034b1add8daf32fa62859bad778e00d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 03/21] =?UTF-8?q?=F0=9F=A7=A9=20feat:=20Model=20Specs=20Ed?= =?UTF-8?q?itor=20(Wave=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model specs editor (WS11, Wave 2) --- e2e/specs.spec.ts | 207 ++++++++ src/components/configuration/edits.test.ts | 38 ++ src/components/configuration/edits.ts | 31 +- .../configuration/fields/ArrayObjectField.tsx | 4 + .../configuration/fields/ObjectEntryCard.tsx | 63 ++- src/components/configuration/meta/index.ts | 2 + src/components/configuration/meta/specs.ts | 34 ++ src/components/configuration/search.ts | 11 +- .../configuration/sections/index.ts | 2 + .../sections/specs/ChipPicker.tsx | 142 ++++++ .../sections/specs/GroupedSelect.tsx | 66 +++ .../sections/specs/ModelSpecsRenderer.tsx | 45 ++ .../sections/specs/PresetEditor.tsx | 261 ++++++++++ .../sections/specs/SpecEditor.tsx | 356 ++++++++++++++ .../sections/specs/SpecFieldRow.tsx | 75 +++ .../sections/specs/SpecFields.tsx | 452 ++++++++++++++++++ .../sections/specs/SpecGroup.tsx | 47 ++ .../configuration/sections/specs/SpecIcon.tsx | 46 ++ .../configuration/sections/specs/SpecList.tsx | 241 ++++++++++ .../configuration/sections/specs/SpecNote.tsx | 36 ++ .../sections/specs/SpecSummary.tsx | 104 ++++ .../sections/specs/SpecsSettings.tsx | 319 ++++++++++++ .../__tests__/ModelSpecsRenderer.test.tsx | 240 ++++++++++ .../sections/specs/__tests__/params.test.ts | 149 ++++++ .../specs/__tests__/validation.test.ts | 126 +++++ .../configuration/sections/specs/context.ts | 16 + .../configuration/sections/specs/groups.ts | 19 + .../configuration/sections/specs/index.ts | 7 + .../configuration/sections/specs/options.ts | 124 +++++ .../configuration/sections/specs/params.ts | 170 +++++++ .../sections/specs/validation.ts | 189 ++++++++ src/hooks/config/save.ts | 5 +- src/hooks/config/sections.ts | 16 +- src/hooks/useActiveSection.ts | 9 + src/locales/en/translation.json | 142 ++++++ src/types/changes.ts | 3 +- src/types/fields.ts | 8 + src/types/index.ts | 1 + src/types/specs.ts | 230 +++++++++ src/utils/collections.test.ts | 33 ++ src/utils/collections.ts | 10 +- src/utils/values.test.ts | 27 ++ src/utils/values.ts | 9 +- 43 files changed, 4080 insertions(+), 35 deletions(-) create mode 100644 e2e/specs.spec.ts create mode 100644 src/components/configuration/meta/specs.ts create mode 100644 src/components/configuration/sections/specs/ChipPicker.tsx create mode 100644 src/components/configuration/sections/specs/GroupedSelect.tsx create mode 100644 src/components/configuration/sections/specs/ModelSpecsRenderer.tsx create mode 100644 src/components/configuration/sections/specs/PresetEditor.tsx create mode 100644 src/components/configuration/sections/specs/SpecEditor.tsx create mode 100644 src/components/configuration/sections/specs/SpecFieldRow.tsx create mode 100644 src/components/configuration/sections/specs/SpecFields.tsx create mode 100644 src/components/configuration/sections/specs/SpecGroup.tsx create mode 100644 src/components/configuration/sections/specs/SpecIcon.tsx create mode 100644 src/components/configuration/sections/specs/SpecList.tsx create mode 100644 src/components/configuration/sections/specs/SpecNote.tsx create mode 100644 src/components/configuration/sections/specs/SpecSummary.tsx create mode 100644 src/components/configuration/sections/specs/SpecsSettings.tsx create mode 100644 src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx create mode 100644 src/components/configuration/sections/specs/__tests__/params.test.ts create mode 100644 src/components/configuration/sections/specs/__tests__/validation.test.ts create mode 100644 src/components/configuration/sections/specs/context.ts create mode 100644 src/components/configuration/sections/specs/groups.ts create mode 100644 src/components/configuration/sections/specs/index.ts create mode 100644 src/components/configuration/sections/specs/options.ts create mode 100644 src/components/configuration/sections/specs/params.ts create mode 100644 src/components/configuration/sections/specs/validation.ts create mode 100644 src/types/specs.ts diff --git a/e2e/specs.spec.ts b/e2e/specs.spec.ts new file mode 100644 index 00000000..85943b57 --- /dev/null +++ b/e2e/specs.spec.ts @@ -0,0 +1,207 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON } from './live'; + +/** + * Model specs journeys (WS11, the audit's specs journeys). They need a live + * LibreChat backend whose librechat.yaml defines at least one model spec and + * whose Base stores no spec list of its own (reset the admin overrides first): + * run with E2E_LIVE=1. Each journey that saves puts the list back to + * librechat.yaml before it ends. + */ +const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' }); +const cards = (page: Page) => page.locator('#section-modelSpecs\\.list [data-entry-card]'); + +async function openSpecs(page: Page, search = '') { + await page.goto(`/configuration?tab=specs${search}`); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { name: /^Specs/ })).toBeVisible(); +} + +async function expand(card: Locator) { + const header = card.locator('button[aria-expanded]').first(); + if ((await header.getAttribute('aria-expanded')) !== 'true') await header.click(); +} + +async function review(page: Page) { + await unsavedBar(page) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + return page.getByRole('dialog'); +} + +async function saveAndWait(page: Page) { + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0); +} + +/** Puts the spec list back to librechat.yaml when the journey left an override. */ +async function revertList(page: Page) { + await openSpecs(page); + const revert = page.getByRole('button', { name: 'Revert list to librechat.yaml' }); + if ((await revert.count()) === 0) return; + await revert.click(); + await review(page); + await saveAndWait(page); +} + +/** Adds a spec with the four required fields, and returns its card. */ +async function addSpec(page: Page, label: string, endpoint: string, model: string) { + await page.getByRole('button', { name: 'Add spec' }).click(); + const card = cards(page).last(); + await expect(card.getByLabel(/^Label\*?$/)).toBeFocused(); + await card.getByLabel(/^Label\*?$/).fill(label); + await card.getByLabel(/^Label\*?$/).press('Tab'); + await card.getByLabel(/^Endpoint\*?$/).selectOption(endpoint); + await card.getByRole('combobox', { name: /^Model\*?$/ }).fill(model); + await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Tab'); + return card; +} + +test.describe('Model specs', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); + test.describe.configure({ mode: 'serial', timeout: 120_000 }); + + test('the tab says how users pick models and what they will see', async ({ page }) => { + await openSpecs(page); + await expect(page.getByRole('group', { name: 'How users pick models' })).toBeVisible(); + await expect(page.getByText('Users will see:')).toBeVisible(); + await expect(cards(page).first()).toBeVisible(); + await expect(page.getByText('List order is the order of the model menu.')).toBeVisible(); + }); + + test('a new spec asks for Endpoint and Model up front and saves at the end of the list', async ({ + page, + }) => { + await openSpecs(page); + const before = await cards(page).count(); + const card = await addSpec(page, 'E2E Spec', 'openAI', 'gpt-4o-mini'); + await expect(card.getByLabel(/^Name\*?$/)).toHaveValue('e2e-spec'); + const dialog = await review(page); + await expect(dialog).toContainText('Added model spec'); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(cards(page)).toHaveCount(before + 1); + await expect(cards(page).last()).toContainText('E2E Spec'); + await expect(cards(page).last()).toContainText('openAI · gpt-4o-mini'); + await revertList(page); + }); + + test('duplicate names and incomplete specs block Save', async ({ page }) => { + await openSpecs(page); + const first = cards(page).first(); + await expand(first); + const name = await first.getByLabel(/^Name\*?$/).inputValue(); + const card = await addSpec(page, 'Copy', 'openAI', 'gpt-4o'); + await card.getByLabel(/^Name\*?$/).fill(name); + await card.getByLabel(/^Name\*?$/).press('Tab'); + await expect(card.getByText('already uses this name')).toBeVisible(); + const dialog = await review(page); + await expect(dialog.getByText(/must be fixed before saving/)).toBeVisible(); + await expect(dialog.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('only one spec can be the default', async ({ page }) => { + await openSpecs(page); + await addSpec(page, 'E2E A', 'openAI', 'gpt-4o'); + const b = await addSpec(page, 'E2E B', 'openAI', 'gpt-4o'); + const a = cards(page).nth((await cards(page).count()) - 2); + for (const card of [a, b]) { + await expand(card); + await card.getByRole('button', { name: /^New chats/ }).click(); + await card.getByRole('button', { name: 'Default', exact: true }).click(); + } + const pressed = (card: Locator) => + card.getByRole('button', { name: 'Default', exact: true }).getAttribute('aria-pressed'); + expect(await pressed(a)).toBe('false'); + expect(await pressed(b)).toBe('true'); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('reordering is saved (list order is menu order)', async ({ page }) => { + await openSpecs(page); + await addSpec(page, 'E2E Last', 'openAI', 'gpt-4o'); + await review(page); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: 'Move E2E Last up' }).click(); + const dialog = await review(page); + await expect(dialog).toContainText('Order changed'); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + const count = await cards(page).count(); + await expect(cards(page).nth(count - 2)).toContainText('E2E Last'); + await revertList(page); + }); + + test('Enter in a spec field never adds or removes an endpoint next to the specs', async ({ + page, + }) => { + await openSpecs(page); + const card = cards(page).first(); + await expand(card); + await card.getByLabel(/^Label\*?$/).press('Enter'); + await card.getByRole('combobox', { name: /^Model\*?$/ }).press('Enter'); + await card.getByLabel('Model label', { exact: true }).press('Enter'); + await expect(unsavedBar(page)).toHaveCount(0); + const picker = page.locator('#modelSpecs-addedEndpoints-add'); + await expect(picker.locator('option[value=""]')).toHaveCount(0); + }); + + test('an Anthropic spec offers only Anthropic parameters', async ({ page }) => { + await openSpecs(page); + const card = await addSpec(page, 'E2E Claude', 'anthropic', 'claude-sonnet-4-5'); + await card.getByRole('button', { name: /^Parameters/ }).click(); + await expect(card.getByLabel('Top K', { exact: true })).toBeVisible(); + await expect(card.getByLabel('Thinking budget', { exact: true })).toBeVisible(); + await expect(card.getByLabel('Frequency penalty', { exact: true })).toHaveCount(0); + await expect(card.getByLabel('Reasoning effort', { exact: true })).toHaveCount(0); + await unsavedBar(page).getByRole('button', { name: 'Discard' }).click(); + }); + + test('a deleted spec waits for Save with Undo', async ({ page }) => { + await openSpecs(page); + const before = await cards(page).count(); + const title = (await cards(page).first().locator('span.truncate').first().innerText()).trim(); + await page.getByRole('button', { name: `Delete ${title}` }).click(); + await expect(page.getByRole('button', { name: new RegExp(`Undo.*${title}`) })).toBeFocused(); + await page.getByRole('button', { name: new RegExp(`Undo.*${title}`) }).click(); + await expect(cards(page)).toHaveCount(before); + await expect(unsavedBar(page)).toHaveCount(0); + }); + + test('a profile shows the specs it inherits read-only until it customizes the list', async ({ + page, + }) => { + await openSpecs(page, '&scope=role:USER'); + const customize = page.getByRole('button', { name: /^Customize list for/ }); + test.skip((await customize.count()) === 0, 'Needs a USER role profile without its own list'); + await expect(page.getByRole('button', { name: 'Add spec' })).toHaveCount(0); + await expect(cards(page).first()).toBeVisible(); + await customize.click(); + await expect(page.getByRole('button', { name: 'Add spec' })).toBeVisible(); + await expect(page.getByText(/replaces Base's list/)).toBeVisible(); + }); + + test('the tab filter finds spec fields and phone width has no sideways scroll', async ({ + page, + }) => { + await openSpecs(page); + await page.getByPlaceholder('Filter this tab…').fill('soft default'); + await expect(page.getByText('Default for new chats').first()).toBeVisible(); + await page.getByPlaceholder('Filter this tab…').fill(''); + await page.setViewportSize({ width: 390, height: 900 }); + await expand(cards(page).first()); + const overflow = await page.evaluate(() => { + const el = document.querySelector('.scrollbar-gutter-stable'); + return el ? el.scrollWidth - el.clientWidth : 0; + }); + expect(overflow).toBeLessThanOrEqual(0); + }); +}); diff --git a/src/components/configuration/edits.test.ts b/src/components/configuration/edits.test.ts index 6c8a4389..da86c5d7 100644 --- a/src/components/configuration/edits.test.ts +++ b/src/components/configuration/edits.test.ts @@ -205,6 +205,44 @@ describe('identity-keyed collection ops (SV-1)', () => { ]); }); + it('sends a reordered spec list with every kept spec at its new index', () => { + const specList: t.ConfigRecord[] = [ + { name: 'a', label: 'A', preset: { endpoint: 'openAI', model: 'm' } }, + { name: 'b', label: 'B', preset: { endpoint: 'openAI', model: 'm' } }, + { name: 'c', label: 'C', preset: { endpoint: 'openAI', model: 'm' } }, + ]; + const specRoot: t.ConfigRecord = { modelSpecs: { list: specList } }; + const reordered = [specList[2], specList[0], specList[1]]; + const collections = trackCollectionEdit({}, {}, 'modelSpecs.list', reordered, specRoot); + const { ops } = buildConfigChanges( + new Set(['modelSpecs.list']), + { 'modelSpecs.list': reordered }, + schemaPaths, + context({ root: specRoot, collections }), + ); + expect(ops).toEqual([ + expect.objectContaining({ op: 'upsertEntry', identity: 'c', index: 0 }), + expect.objectContaining({ op: 'upsertEntry', identity: 'a', index: 1 }), + expect.objectContaining({ op: 'upsertEntry', identity: 'b', index: 2 }), + ]); + const plan = planConfigChanges(ops, { stored: {}, lower: specRoot, lowerKind: 'yaml' }); + expect(plan.sets.map((set) => set.fieldPath)).toEqual(['modelSpecs.list']); + const saved = plan.sets[0].value as t.ConfigRecord[]; + expect(saved.map((spec) => spec.name)).toEqual(['c', 'a', 'b']); + }); + + it('sends no positions for a reordered custom endpoint list (merged by name)', () => { + const reordered = [endpoints[2], endpoints[0], endpoints[1]]; + const collections = trackCollectionEdit({}, {}, 'endpoints.custom', reordered, root); + const { ops } = buildConfigChanges( + new Set(['endpoints.custom']), + { 'endpoints.custom': reordered }, + schemaPaths, + context({ collections }), + ); + expect(ops).toEqual([]); + }); + it('turns a reset of the whole collection into a reset op (revert list to librechat.yaml)', () => { const values = { 'modelSpecs.list': undefined }; const { ops } = buildConfigChanges( diff --git a/src/components/configuration/edits.ts b/src/components/configuration/edits.ts index 54f8d903..456baf67 100644 --- a/src/components/configuration/edits.ts +++ b/src/components/configuration/edits.ts @@ -303,11 +303,36 @@ export function trackCollectionEdit( }; } +/** + * Whether the pending items keep loaded entries in a different order than + * they were loaded in. Only lists whose order matters (model specs: list + * order is menu order) send it. + */ +function isReordered( + collection: t.KeyedCollection, + items: t.ConfigValue[], + edit: t.CollectionEdit, +): boolean { + if (collection.merge !== 'replace') return false; + const pending = items.flatMap((_, index) => { + const origin = edit.origins[index]; + return origin ? [origin.identity] : []; + }); + const kept = new Set(pending); + const loaded = edit.baseline.flatMap((entry) => { + const identity = getEntryKey(entry, collection.key); + return identity !== undefined && kept.has(identity) ? [identity] : []; + }); + return pending.some((identity, i) => identity !== loaded[i]); +} + /** * The entry ops for one collection: an upsert for each added or changed item * (keyed by the identity it was loaded with, with that loaded entry as the * baseline so only the edit is applied) and a remove for each loaded entry no - * pending item came from. Items without a key are reported, not sent. + * pending item came from. Items without a key are reported, not sent. When a + * list whose order matters was reordered, every kept entry is sent with its + * new `index`, so applying the upserts in order rebuilds that order. */ export function diffCollection( collection: t.KeyedCollection, @@ -318,6 +343,7 @@ export function diffCollection( const ops: t.ConfigChange[] = []; const issues: t.ConfigIssue[] = []; const kept = new Set(); + const moved = isReordered(collection, items, edit); items.forEach((item, index) => { const origin = edit.origins[index] ?? null; if (origin) kept.add(origin.identity); @@ -339,7 +365,7 @@ export function diffCollection( return; } const baseline = normalize(origin.baseline); - if (isConfigValueEqual(value, baseline)) return; + if (!moved && isConfigValueEqual(value, baseline)) return; ops.push({ op: 'upsertEntry', path: collection.path, @@ -347,6 +373,7 @@ export function diffCollection( identity: origin.identity, value, baseline, + ...(moved && { index }), }); }); for (const entry of edit.baseline) { diff --git a/src/components/configuration/fields/ArrayObjectField.tsx b/src/components/configuration/fields/ArrayObjectField.tsx index b0322e65..9bf0926b 100644 --- a/src/components/configuration/fields/ArrayObjectField.tsx +++ b/src/components/configuration/fields/ArrayObjectField.tsx @@ -29,6 +29,8 @@ export function ArrayObjectField({ editSessionId, getEntryTitle: getTitle, renderSummary, + renderLeading, + stackedSummary, reorderable, emptyLabel, isEntryLocked, @@ -192,6 +194,8 @@ export function ArrayObjectField({ reorderable && index < items.length - 1 ? () => handleMove(index, 1) : undefined } summary={renderSummary?.(item, index)} + leading={renderLeading?.(item, index)} + stackedSummary={stackedSummary} disabled={disabled} isNew={newKeys.has(key)} markers={newKeys.has(key) ? undefined : markersOf?.(item)} diff --git a/src/components/configuration/fields/ObjectEntryCard.tsx b/src/components/configuration/fields/ObjectEntryCard.tsx index a4ed77c2..dd836e28 100644 --- a/src/components/configuration/fields/ObjectEntryCard.tsx +++ b/src/components/configuration/fields/ObjectEntryCard.tsx @@ -45,6 +45,8 @@ export function ObjectEntryCard({ isNew, markers, summary, + leading, + stackedSummary, disabled, defaultExpanded = false, renderFields, @@ -173,6 +175,12 @@ export function ObjectEntryCard({ }, }); + const newChip = isNew ? ( + + {localize('com_config_ctl_entry_new')} + + ) : null; + if (pendingRemoval) { return (
    - - {entryKey} - - {isNew && ( - - {localize('com_config_ctl_entry_new')} - - )} - {markers && } - {summaryNode && !isExpanded && ( - - {summaryNode} + {leading} + {stackedSummary ? ( + + + + {entryKey} + + {newChip} + {markers && } + + {summaryNode && !isExpanded && ( + + {summaryNode} + + )} + ) : ( + <> + + {entryKey} + + {newChip} + {markers && } + {summaryNode && !isExpanded && ( + + {summaryNode} + + )} + )} )} diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index 21606291..f2cae690 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -4,12 +4,14 @@ import { PROVIDER_FIELD_META } from './providers'; import { SECRET_CONFIG_KEYS } from '@/constants'; import { ENDPOINT_FIELD_META } from './endpoints'; import { GENERIC_FIELD_META } from './generic'; +import { SPEC_FIELD_META } from './specs'; /** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */ const META_SOURCES: t.FieldMetaRegistry[] = [ GENERIC_FIELD_META, PROVIDER_FIELD_META, ENDPOINT_FIELD_META, + SPEC_FIELD_META, ]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); diff --git a/src/components/configuration/meta/specs.ts b/src/components/configuration/meta/specs.ts new file mode 100644 index 00000000..0ef218cb --- /dev/null +++ b/src/components/configuration/meta/specs.ts @@ -0,0 +1,34 @@ +import type * as t from '@/types'; + +/** + * Model spec field metadata (WS11): labels and help where the schema's own + * read wrong in the spec editor, the review and search. + */ +export const SPEC_FIELD_META: t.FieldMetaRegistry = { + 'modelSpecs.list': { labelKey: 'com_config_specs_list' }, + /** LibreChat lists specs in list order; `order` sorts nothing in its model menu. */ + 'modelSpecs.list[].order': { descriptionKey: 'com_config_specs_order_desc' }, + /** The provider's own search tool, apart from the spec's Web search capability. */ + 'modelSpecs.list[].preset.web_search': { labelKey: 'com_config_specs_provider_web_search' }, + 'modelSpecs.list[].preset.greeting': { multiline: true }, + /** Unset, the spec's agent gets no ask-the-user tool. */ + 'modelSpecs.list[].askUserQuestion': { runtimeDefault: false }, + /** Unset, LibreChat lets the provider pick (the parameter panel's "Auto"). */ + ...Object.fromEntries( + [ + 'effort', + 'reasoning_effort', + 'reasoning_summary', + 'reasoning_mode', + 'reasoning_context', + 'verbosity', + 'thinkingLevel', + ].map((key) => [ + `modelSpecs.list[].preset.${key}`, + { defaultLabelKey: 'com_config_specs_default_auto' }, + ]), + ), + 'modelSpecs.list[].preset.promptCacheTtl': { + defaultLabelKey: 'com_config_specs_default_provider', + }, +}; diff --git a/src/components/configuration/search.ts b/src/components/configuration/search.ts index 0ad9b924..fcd37180 100644 --- a/src/components/configuration/search.ts +++ b/src/components/configuration/search.ts @@ -19,6 +19,7 @@ import { getFieldMeta, labelForPath, describeField } from './meta'; import { formatFieldValue } from './controls'; import { inferTransportType } from './sections/mcp/validation'; import { isMcpFieldShown } from './sections/mcpFieldMeta'; +import { specSearchFields } from './sections/specs/params'; import { getEntryTitle } from './fields/entries'; const VALUE_MAX = 40; @@ -219,7 +220,15 @@ function walkField(ctx: WalkContext, field: t.SchemaField, path: string, breadcr pushItem(ctx, { label: title, breadcrumb: entryCrumbs, path: entryPath, kind: 'entry' }, [ segment, ]); - walkChildren(ctx, field.children ?? [], entryPath, crumbs([...entryCrumbs, title])); + const children = + path === 'modelSpecs.list' + ? specSearchFields( + field.children ?? [], + getValueAtPath(values ?? null, entryPath), + values ?? null, + ) + : (field.children ?? []); + walkChildren(ctx, children, entryPath, crumbs([...entryCrumbs, title])); } return; } diff --git a/src/components/configuration/sections/index.ts b/src/components/configuration/sections/index.ts index bb2717be..7dcca87a 100644 --- a/src/components/configuration/sections/index.ts +++ b/src/components/configuration/sections/index.ts @@ -15,6 +15,7 @@ import { McpServersRenderer } from './McpServersRenderer'; import { CustomEndpointsRenderer } from './endpoints'; import { LangfuseRenderer } from './LangfuseRenderer'; import { ProvidersRenderer } from './providers'; +import { ModelSpecsRenderer } from './specs'; export const SECTION_RENDERERS: Partial>> = { @@ -22,6 +23,7 @@ export const SECTION_RENDERERS: Partial(null); + const labels = new Map( + groups.flatMap((group) => group.options.map((option) => [option.value, option.label])), + ); + const picked = new Set(values); + const remaining = groups + .map((group) => ({ + ...group, + options: group.options.filter((option) => !picked.has(option.value)), + })) + .filter((group) => group.options.length > 0); + + const add = (value: string) => { + setNotice(null); + setAnnouncement(localize('com_config_specs_chip_added', { name: labels.get(value) ?? value })); + onChange([...values, value]); + }; + const remove = (value: string) => { + const next = values.filter((item) => item !== value); + setAnnouncement( + localize('com_config_specs_chip_removed', { name: labels.get(value) ?? value }), + ); + if (next.length > 0 || !onClear) { + setNotice(null); + onChange(next); + return; + } + setNotice(onClear() === false ? localize('com_config_ctl_clear_unavailable') : null); + }; + + return ( +
    + {values.length > 0 && ( +
      + {values.map((value) => { + const label = labels.get(value); + const known = label !== undefined; + return ( +
    • + {!known && } + {known ? label : unknownLabel(value)} + {!disabled && ( + + )} +
    • + ); + })} +
    + )} + {!disabled && remaining.length > 0 && ( +
    + + +
    + )} + {disabled && values.length === 0 && ( + + {localize('com_config_specs_chip_none')} + + )} + {notice && {notice}} + + {announcement} + +
    + ); +} diff --git a/src/components/configuration/sections/specs/GroupedSelect.tsx b/src/components/configuration/sections/specs/GroupedSelect.tsx new file mode 100644 index 00000000..5d2c89f7 --- /dev/null +++ b/src/components/configuration/sections/specs/GroupedSelect.tsx @@ -0,0 +1,66 @@ +import { Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { INPUT_CLASSES, widthClass } from '../../fields/display'; +import { cn } from '@/utils'; + +const PLACEHOLDER = '\u0000placeholder'; + +/** + * A native select with option groups (Providers, Custom endpoints), so the + * label targets the control and the OS list keeps type-ahead. A value that + * matches no option is kept, selected and labelled as unknown. + */ +export function GroupedSelect({ + id, + value, + groups, + onChange, + placeholder, + unknownLabel, + required, + disabled, + 'aria-describedby': ariaDescribedBy, +}: t.GroupedSelectProps) { + const isUnset = value == null || value === ''; + const isKnown = + isUnset || groups.some((group) => group.options.some((option) => option.value === value)); + return ( +
    + + +
    + ); +} diff --git a/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx b/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx new file mode 100644 index 00000000..1748e5d3 --- /dev/null +++ b/src/components/configuration/sections/specs/ModelSpecsRenderer.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import type * as t from '@/types'; +import { SPEC_LIST_PATH, validateSpecs } from './validation'; +import { UnknownSettings } from '../../UnknownSettings'; +import { SpecsSettings } from './SpecsSettings'; +import { useSpecOptions } from './options'; +import { SpecList } from './SpecList'; +import { isConfigRecord } from '@/utils'; + +const NO_ITEMS: t.ConfigValue[] = []; + +/** + * The Model specs tab (WS11): how users pick models (specs only, specs + * first, endpoints first) with the endpoints listed beside the specs, then + * the spec list and its editor. Specs are validated as the admin edits them; + * errors show on the spec and its fields and block Save (MS-2). + */ +export function ModelSpecsRenderer(props: t.ModelSpecsRendererProps) { + const { fields, parentValue, parentPath, getValue } = props; + const listField = fields.find((field) => field.key === 'list'); + const stored = isConfigRecord(parentValue) ? parentValue.list : undefined; + const value = getValue(SPEC_LIST_PATH, stored ?? NO_ITEMS); + const items = Array.isArray(value) ? value : NO_ITEMS; + const options = useSpecOptions(items); + const problems = useMemo( + () => validateSpecs(items, { knownEndpoints: options.knownEndpoints }), + [items, options.knownEndpoints], + ); + + return ( + <> + + {listField && ( + + )} + + + ); +} diff --git a/src/components/configuration/sections/specs/PresetEditor.tsx b/src/components/configuration/sections/specs/PresetEditor.tsx new file mode 100644 index 00000000..24c1ae58 --- /dev/null +++ b/src/components/configuration/sections/specs/PresetEditor.tsx @@ -0,0 +1,261 @@ +import { useMemo, useState, useEffect, useRef } from 'react'; +import { Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { MODEL_KEYS, PROMPT_KEYS, PRESET_DENYLIST, paramField, presetParams } from './params'; +import { INPUT_CLASSES, widthClass } from '../../fields/display'; +import { renderInlineField, toFieldId } from '../../renderer'; +import { describeField, labelForPath } from '../../meta'; +import { useFieldError, useLocalize } from '@/hooks'; +import { FieldControl } from '../../renderer/FieldControl'; +import { CodeField } from '../../fields/CodeField'; +import { SettingsRow } from '../../SettingsRow'; +import { specEndpoint } from './validation'; +import { useSpecList } from './context'; +import { cn, isConfigRecord } from '@/utils'; + +const PICK = '\u0000pick'; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** One model parameter: the shared control with the endpoint's range, choices and default. */ +function ParamRow({ + field, + param, + value, + path, + onChange, + disabled, + editSessionId, +}: { + field: t.SchemaField; + param: t.PresetParam; + value: t.ConfigValue; + path: string; + onChange: (key: string, value: t.ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; +}) { + const localize = useLocalize(); + const dataPath = `${path}.${param.key}`; + const id = toFieldId(dataPath); + const label = labelForPath(dataPath, localize); + const error = useFieldError(dataPath); + const shaped = paramField(field, param); + return ( + onChange(param.key, next)} + onClear={() => { + onChange(param.key, undefined); + return true; + }} + fieldDefault={{ + hasOwnValue: value !== undefined, + value: param.defaultValue, + source: 'runtimeDefault', + }} + disabled={disabled} + editSessionId={editSessionId} + /> + } + /> + ); +} + +/** + * A spec's model parameters (MS-4), driven by LibreChat's own parameter + * settings for its endpoint and model: only the parameters that endpoint + * offers, with their ranges, choices and defaults. Preset fields the spec + * sets that the endpoint doesn't offer stay listed (so they can be changed or + * removed); "Advanced (raw)" adds any other preset field LibreChat accepts, + * internal conversation fields excepted, or edits the preset as YAML. + */ +export function PresetEditor({ + presetField, + preset, + path, + onChange, + onReplace, + disabled, + editSessionId, +}: t.PresetEditorProps) { + const localize = useLocalize(); + const { options, configuredOnly } = useSpecList(); + const [addedKeys, setAddedKeys] = useState([]); + const [rawOpen, setRawOpen] = useState(false); + const focusKeyRef = useRef(null); + const endpoint = specEndpoint({ preset }); + const model = textOf(preset.model); + const children = presetField.children ?? []; + const byKey = useMemo(() => new Map(children.map((field) => [field.key, field])), [children]); + const { params } = useMemo( + () => presetParams(endpoint, model, options.customEndpoint(endpoint), new Set(byKey.keys())), + [endpoint, model, options, byKey], + ); + const endpointLabel = + options.endpoints.find((option) => option.value === endpoint)?.label ?? endpoint; + + const handled = new Set([...MODEL_KEYS, ...PROMPT_KEYS, ...params.map((param) => param.key)]); + const others = children.filter( + (field) => + !handled.has(field.key) && (preset[field.key] !== undefined || addedKeys.includes(field.key)), + ); + const pickable = children + .filter( + (field) => + !handled.has(field.key) && + !PRESET_DENYLIST.has(field.key) && + preset[field.key] === undefined && + !addedKeys.includes(field.key), + ) + .map((field) => ({ + value: field.key, + label: localize('com_config_specs_raw_option', { + label: labelForPath(`${path}.${field.key}`, localize), + key: field.key, + }), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + useEffect(() => { + const key = focusKeyRef.current; + if (!key) return; + focusKeyRef.current = null; + const el = document.getElementById(toFieldId(`${path}.${key}`)); + const target = el?.matches('input, select, textarea, button') + ? el + : el?.querySelector('input, select, textarea, button'); + target?.focus(); + }); + + return ( + <> + {!endpoint && !configuredOnly && ( +

    {localize('com_config_specs_params_no_endpoint')}

    + )} + {endpoint && params.length === 0 && !configuredOnly && ( +

    + {localize('com_config_specs_params_none', { endpoint: endpointLabel })} +

    + )} + {params.map((param) => { + const field = byKey.get(param.key); + if (!field || (configuredOnly && preset[param.key] === undefined)) return null; + return ( + + ); + })} + {others.length > 0 && ( + <> +

    + + {localize('com_config_specs_params_other')} + {' '} + {localize('com_config_specs_params_other_desc', { + endpoint: endpointLabel || localize('com_config_specs_this_endpoint'), + })} +

    + {others.map((field) => + renderInlineField( + field, + preset, + path, + onChange, + localize, + disabled, + undefined, + false, + editSessionId, + ), + )} + + )} + {!disabled && !configuredOnly && ( +
    + + {rawOpen && ( +
    +

    + {localize('com_config_specs_raw_desc')} +

    + {pickable.length > 0 && ( +
    + + +
    + )} +
    + + onReplace(isConfigRecord(next) ? next : {})} + /> +
    +
    + )} +
    + )} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecEditor.tsx b/src/components/configuration/sections/specs/SpecEditor.tsx new file mode 100644 index 00000000..a616ba11 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecEditor.tsx @@ -0,0 +1,356 @@ +import { useMemo, useRef, useCallback } from 'react'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { + NameField, + McpField, + IconField, + ModelField, + AgentField, + LabelField, + GroupField, + DefaultField, + EndpointField, +} from './SpecFields'; +import { isAgentSpec, problemsOf, specEndpoint } from './validation'; +import { UnknownSettings } from '../../UnknownSettings'; +import { renderInlineField } from '../../renderer'; +import { PresetEditor } from './PresetEditor'; +import { useSpecList, specIndexOf } from './context'; +import { useLocalize } from '@/hooks'; +import { SpecGroup } from './SpecGroup'; +import { promptKeyFor } from './params'; +import { isConfigRecord } from '@/utils'; + +const BASICS = ['label', 'name', 'description', 'iconURL']; +const CAPABILITIES = [ + 'webSearch', + 'fileSearch', + 'executeCode', + 'memory', + 'askUserQuestion', + 'artifacts', + 'runInBackground', + 'describeIntent', + 'skills', + 'mcpServers', +]; +const MENU = [ + 'showInMenu', + 'group', + 'groupIcon', + 'showIconInMenu', + 'showIconInHeader', + 'showOnLanding', + 'hideBadgeRow', + 'conversation_starters', +]; +const DEFAULTS = ['default', 'softDefault']; +const SUBAGENTS = 'subagents'; +const ADVANCED = ['authType', 'order']; +const GROUPED: ReadonlySet = new Set([ + ...BASICS, + ...CAPABILITIES, + ...MENU, + ...DEFAULTS, + ...ADVANCED, + SUBAGENTS, + 'preset', +]); + +const ASSISTANT_ENDPOINTS: ReadonlySet = new Set([ + EModelEndpoint.assistants, + EModelEndpoint.azureAssistants, +]); + +function hasValue(value: t.ConfigValue): boolean { + if (value === undefined || value === null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + return !isConfigRecord(value) || Object.keys(value).length > 0; +} + +function countSet(record: t.ConfigRecord, keys: readonly string[]): number { + return keys.filter((key) => hasValue(record[key])).length; +} + +/** + * Writes one key of an object field (`preset`, `subagents`), merged into its + * latest value so two changes in one tick both land. An emptied optional + * object is removed; the required preset stays an object. + */ +function useChildChange( + parent: t.ConfigRecord, + key: string, + onChange: (key: string, value: t.ConfigValue) => void, + keepEmpty: boolean, +) { + const latest = useRef(parent); + latest.current = parent; + const change = useCallback( + (childKey: string, childValue: t.ConfigValue) => { + const next = { ...latest.current, [childKey]: childValue }; + if (childValue === undefined) delete next[childKey]; + latest.current = next; + onChange(key, keepEmpty || Object.keys(next).length > 0 ? next : undefined); + }, + [key, onChange, keepEmpty], + ); + const replace = useCallback( + (next: t.ConfigRecord) => { + latest.current = next; + onChange(key, next); + }, + [key, onChange], + ); + return { change, replace }; +} + +/** + * The editor of one model spec (MS-1), in groups: Basics, Model, Parameters, + * Prompt, Capabilities, Menu, New chats, Subagents and Advanced. Basics and + * Model (with the required name, label, endpoint and model) open first; + * other groups open for a problem or a search hit inside them. Fields the + * schema adds later land in Advanced; keys the panel doesn't know are listed + * read-only (D8). + */ +export function SpecEditor({ + fields, + value, + path, + onChange, + disabled, + editSessionId, +}: t.SpecEditorProps) { + const localize = useLocalize(); + const { problems, options, configuredOnly } = useSpecList(); + const index = specIndexOf(path); + const spec = useMemo(() => (isConfigRecord(value) ? value : {}), [value]); + const byKey = useMemo(() => new Map(fields.map((field) => [field.key, field])), [fields]); + const presetField = byKey.get('preset'); + const subagentsField = byKey.get(SUBAGENTS); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const subagents = isConfigRecord(spec.subagents) ? spec.subagents : {}; + const presetChange = useChildChange(preset, 'preset', onChange, true); + const subagentsChange = useChildChange(subagents, SUBAGENTS, onChange, false); + const presetPath = `${path}.preset`; + const endpoint = specEndpoint(spec); + const isAgent = isAgentSpec(spec); + const promptKey = promptKeyFor( + endpoint, + typeof preset.model === 'string' ? preset.model : '', + options.customEndpoint(endpoint), + ); + const promptKeys = [ + promptKey, + 'greeting', + ...(ASSISTANT_ENDPOINTS.has(endpoint) || hasValue(preset.instructions) ? ['instructions'] : []), + ...(ASSISTANT_ENDPOINTS.has(endpoint) || hasValue(preset.additional_instructions) + ? ['additional_instructions'] + : []), + ]; + const errorFields = new Set( + problemsOf(problems, index) + .filter((problem) => problem.level === 'error') + .map((problem) => problem.field), + ); + const opensFor = (keys: string[]) => keys.some((key) => errorFields.has(key)); + const paths = (keys: string[], base = path) => keys.map((key) => `${base}.${key}`); + const rest = fields.filter((field) => !GROUPED.has(field.key)); + + /** Under "Set values only", a row (or group) without a value is left out. */ + const shows = (record: t.ConfigRecord, key: string) => !configuredOnly || hasValue(record[key]); + const showsGroup = (count: number) => !configuredOnly || count > 0; + const modelKeys = ['endpoint', 'model', 'agent_id', 'modelLabel', 'assistant_id']; + const paramCount = Object.keys(preset).filter( + (key) => !modelKeys.includes(key) && !promptKeys.includes(key), + ).length; + const subagentKeys = (subagentsField?.children ?? []).map((field) => field.key); + const advancedKeys = [...ADVANCED, ...rest.map((field) => field.key)]; + + const row = (key: string) => { + const field = byKey.get(key); + if (!field || !shows(spec, key)) return null; + return renderInlineField( + field, + spec, + path, + onChange, + localize, + disabled, + undefined, + true, + editSessionId, + ); + }; + const presetRow = (key: string) => { + const field = presetField?.children?.find((child) => child.key === key); + if (!field || !shows(preset, key)) return null; + return renderInlineField( + field, + preset, + presetPath, + presetChange.change, + localize, + disabled, + undefined, + false, + editSessionId, + ); + }; + const presetChildField = (key: string) => + presetField?.children?.find((child) => child.key === key); + const fieldProps = { spec, index, path, onChange, disabled }; + const presetProps = { ...fieldProps, onPresetChange: presetChange.change }; + + return ( +
    + {showsGroup(countSet(spec, BASICS)) && ( + + {shows(spec, 'label') && } + {shows(spec, 'name') && } + {row('description')} + {shows(spec, 'iconURL') && } + + )} + + + {shows(preset, 'endpoint') && ( + + )} + {isAgent && shows(preset, 'agent_id') && ( + + )} + {!isAgent && shows(preset, 'model') && ( + + )} + {isAgent && hasValue(preset.model) && presetRow('model')} + {ASSISTANT_ENDPOINTS.has(endpoint) && presetRow('assistant_id')} + {presetRow('modelLabel')} + + + {presetField && showsGroup(paramCount) && ( + + + + )} + + {showsGroup(countSet(preset, promptKeys)) && ( + + {promptKeys.map((key) => presetRow(key))} + + )} + + {showsGroup(countSet(spec, CAPABILITIES)) && ( + + {CAPABILITIES.filter((key) => key !== 'mcpServers').map((key) => row(key))} + {shows(spec, 'mcpServers') && ( + + )} + + )} + + {showsGroup(countSet(spec, MENU)) && ( + + {row('showInMenu')} + {shows(spec, 'group') && } + {shows(spec, 'groupIcon') && ( + + )} + {MENU.slice(3).map((key) => row(key))} + + )} + + {showsGroup(countSet(spec, DEFAULTS)) && ( + + + + )} + + {subagentsField && showsGroup(countSet(subagents, subagentKeys)) && ( + + {(subagentsField.children ?? []).map((field) => + !shows(subagents, field.key) + ? null + : renderInlineField( + field, + subagents, + `${path}.${SUBAGENTS}`, + subagentsChange.change, + localize, + disabled, + undefined, + false, + editSessionId, + ), + )} + + )} + + {showsGroup(countSet(spec, advancedKeys)) && ( + + {ADVANCED.map((key) => row(key))} + {rest.map((field) => row(field.key))} + + {presetField && ( + + )} + + )} +
    + ); +} diff --git a/src/components/configuration/sections/specs/SpecFieldRow.tsx b/src/components/configuration/sections/specs/SpecFieldRow.tsx new file mode 100644 index 00000000..d6f7597f --- /dev/null +++ b/src/components/configuration/sections/specs/SpecFieldRow.tsx @@ -0,0 +1,75 @@ +import { useState, useContext } from 'react'; +import type * as t from '@/types'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { useFieldError, useLocalize } from '@/hooks'; +import { toFieldId } from '../../renderer'; +import { SettingsRow } from '../../SettingsRow'; +import { isRowVisible } from '@/utils'; + +/** Codes whose message is a predicate of the field ("is required"), read after its label. */ +const PREDICATE_CODES: ReadonlySet = new Set(['required']); + +/** A spec problem as text: "Label is required", "Spec 1 (“GPT”) already uses this name". */ +export function problemText(problem: t.SpecProblem, label: string, localize: t.LocalizeFn): string { + const text = localize(`com_config_issue_${problem.code}`, problem.params); + return PREDICATE_CODES.has(problem.code) ? `${label} ${text}` : text; +} + +/** + * A spec editor row with its own control (Name, Endpoint, Model, Default…): + * the row grid, its required marker, and its problem: an error under the + * control (else the save pipeline's error for the field), or a warning that + * doesn't block Save. + */ +export function SpecFieldRow({ + path, + label, + description, + required, + problem, + control, + width, + quietWhenEmpty, + matchPaths, +}: t.SpecFieldRowProps) { + const localize = useLocalize(); + const rowFilter = useContext(RowFilterContext); + const pipelineError = useFieldError(path); + const [visited, setVisited] = useState(false); + if (![path, ...(matchPaths ?? [])].some((candidate) => isRowVisible(rowFilter, candidate))) { + return null; + } + const id = toFieldId(path); + const quiet = Boolean(quietWhenEmpty) && !visited; + const text = problem ? problemText(problem, label, localize) : undefined; + const reported = problem?.level === 'error' ? text : pipelineError; + const error = quiet ? undefined : reported; + const warning = + problem?.level === 'warning' ? ( +

    + {text} +

    + ) : null; + return ( + { + if (!event.currentTarget.contains(event.relatedTarget)) setVisited(true); + }} + > + {control} + {warning} +
    + } + /> + ); +} diff --git a/src/components/configuration/sections/specs/SpecFields.tsx b/src/components/configuration/sections/specs/SpecFields.tsx new file mode 100644 index 00000000..b8d92419 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecFields.tsx @@ -0,0 +1,452 @@ +import { useId, useState } from 'react'; +import { ButtonGroup } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { useLocalize, useOptionSources } from '@/hooks'; +import { endpointGroups, mcpGroups } from './groups'; +import { problemAt, specEndpoint } from './validation'; +import { TextField } from '../../fields/TextField'; +import { describeField, labelForPath } from '../../meta'; +import { GroupedSelect } from './GroupedSelect'; +import { SpecFieldRow } from './SpecFieldRow'; +import { toFieldId } from '../../renderer'; +import { uniqueOptions } from './options'; +import { ChipPicker } from './ChipPicker'; +import { useSpecList } from './context'; +import { SpecIcon } from './SpecIcon'; +import { isConfigRecord } from '@/utils'; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value : ''; +} + +/** "OpenAI GPT-5" → "openai-gpt-5", made unique among the other specs' names. */ +export function slugify(label: string, taken: ReadonlySet): string { + const base = + label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'spec'; + if (!taken.has(base)) return base; + let n = 2; + while (taken.has(`${base}-${n}`)) n++; + return `${base}-${n}`; +} + +function otherNames(items: t.ConfigValue[], index: number): Set { + return new Set( + items.flatMap((item, i) => + i !== index && isConfigRecord(item) && typeof item.name === 'string' ? [item.name] : [], + ), + ); +} + +/** A spec added in this session (its name isn't one the list was loaded with). */ +function useIsNewSpec(spec: t.ConfigRecord): boolean { + const { savedNames } = useSpecList(); + return !savedNames.has(textOf(spec.name)); +} + +function useRowLabel(path: string, field?: t.SchemaField) { + const localize = useLocalize(); + return { + label: labelForPath(path, localize), + description: field ? describeField(field, localize) : undefined, + }; +} + +/** + * Label*: the name the model menu shows. On a new spec whose name still + * follows the label, the name is derived from it ("GPT 5 mini" → "gpt-5-mini"). + */ +export function LabelField({ field, spec, index, path, onChange, disabled }: t.SpecFieldProps) { + const { items, problems, savedNames } = useSpecList(); + const isNew = useIsNewSpec(spec); + const dataPath = `${path}.label`; + const { label, description } = useRowLabel(dataPath, field); + const name = textOf(spec.name); + const followsLabel = + !savedNames.has(name) && + (name === '' || name === slugify(textOf(spec.label), otherNames(items, index))); + return ( + { + onChange('label', next); + if (followsLabel && next.trim() !== '') { + onChange('name', slugify(next, otherNames(items, index))); + } + }} + /> + } + /> + ); +} + +/** Name*: the id conversations and `?spec=` links use; renaming a saved spec says what it breaks. */ +export function NameField({ field, spec, index, path, onChange, disabled }: t.SpecFieldProps) { + const localize = useLocalize(); + const { problems, savedNames } = useSpecList(); + const isNew = useIsNewSpec(spec); + const [initialName] = useState(() => textOf(spec.name)); + const dataPath = `${path}.name`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'name'); + const renamed = + initialName !== '' && savedNames.has(initialName) && textOf(spec.name) !== initialName; + const warningId = `${toFieldId(dataPath)}-rename`; + return ( + + onChange('name', next)} + /> + {renamed && ( +

    + {localize('com_config_specs_rename_warning', { name: initialName })} +

    + )} + + } + /> + ); +} + +/** Icon: an image URL or a provider name, with a preview of what the model menu shows. */ +export function IconField({ + field, + spec, + path, + onChange, + disabled, + keyName = 'iconURL', +}: t.SpecIconFieldProps) { + const localize = useLocalize(); + const listId = useId(); + const { options } = useSpecList(); + const dataPath = `${path}.${keyName}`; + const { label, description } = useRowLabel(dataPath, field); + const value = textOf(spec[keyName]); + const title = textOf(keyName === 'groupIcon' ? spec.group : spec.label) || label; + return ( + + + onChange(keyName, next)} + onClear={() => { + onChange(keyName, undefined); + return true; + }} + /> + + {options.endpoints + .filter((option) => !options.customEndpoints.has(option.value)) + .map((option) => ( + + ))} + +
    + } + /> + ); +} + +/** Group: a group the other specs use, an endpoint name (nests the spec under it), or a new name. */ +export function GroupField({ + field, + spec, + path, + onChange, + disabled, +}: Omit) { + const localize = useLocalize(); + const listId = useId(); + const { options } = useSpecList(); + const dataPath = `${path}.group`; + const { label, description } = useRowLabel(dataPath, field); + return ( + + onChange('group', next)} + onClear={() => { + onChange('group', undefined); + return true; + }} + /> + + {options.groups.map((option) => ( + + ))} + + + } + /> + ); +} + +/** Endpoint*: a built-in provider or a custom endpoint; one the configuration lacks is flagged. */ +export function EndpointField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const localize = useLocalize(); + const { options, problems } = useSpecList(); + const dataPath = `${path}.preset.endpoint`; + const { label, description } = useRowLabel(dataPath, field); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const problem = problemAt(problems, index, 'preset.endpoint'); + const isNew = useIsNewSpec(spec); + return ( + localize('com_config_specs_not_configured', { name })} + aria-describedby={ + problem?.level === 'warning' ? `${toFieldId(dataPath)}-warning` : undefined + } + onChange={(next) => onPresetChange('endpoint', next)} + /> + } + /> + ); +} + +/** Model*: free text with the endpoint's known models suggested (fetched models can't be listed here). */ +export function ModelField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const localize = useLocalize(); + const listId = useId(); + const { options, problems } = useSpecList(); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const endpoint = specEndpoint(spec); + const source = useOptionSources(endpoint ? `models:${endpoint}` : undefined); + const suggestions = uniqueOptions([...options.modelsFor(endpoint), ...source.options]); + const dataPath = `${path}.preset.model`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'preset.model'); + const isNew = useIsNewSpec(spec); + return ( + + onPresetChange('model', next)} + /> + + {suggestions.map((option) => ( + + ))} + + + } + /> + ); +} + +/** Agent ID*: the agent an agents-endpoint spec runs (its model comes from the agent). */ +export function AgentField({ + field, + spec, + index, + path, + onPresetChange, + disabled, +}: t.SpecPresetFieldProps) { + const { problems } = useSpecList(); + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const dataPath = `${path}.preset.agent_id`; + const { label, description } = useRowLabel(dataPath, field); + const problem = problemAt(problems, index, 'preset.agent_id'); + const isNew = useIsNewSpec(spec); + return ( + onPresetChange('agent_id', next)} + /> + } + /> + ); +} + +const DEFAULT_KINDS: readonly t.SpecDefaultKind[] = ['none', 'soft', 'default']; + +/** Default for new chats: No, Soft default or Default; choosing one moves it off any other spec. */ +export function DefaultField({ spec, index, path, disabled }: Omit) { + const localize = useLocalize(); + const { problems, actions } = useSpecList(); + const dataPath = `${path}.default`; + const kind: t.SpecDefaultKind = (() => { + if (spec.default === true) return 'default'; + return spec.softDefault === true ? 'soft' : 'none'; + })(); + const problem = + problemAt(problems, index, 'default') ?? problemAt(problems, index, 'softDefault'); + const label = localize('com_config_specs_default'); + return ( + ({ + value, + label: localize(`com_config_specs_default_${value}`), + disabled, + }))} + selected={kind} + onClick={(next) => { + if (disabled || next === kind) return; + actions.setDefault(index, next as t.SpecDefaultKind); + }} + /> + } + /> + ); +} + +/** MCP servers: picked from the servers the configuration defines. */ +export function McpField({ + field, + spec, + path, + onChange, + disabled, +}: Omit) { + const localize = useLocalize(); + const { options } = useSpecList(); + const dataPath = `${path}.mcpServers`; + const { label, description } = useRowLabel(dataPath, field); + const values = Array.isArray(spec.mcpServers) + ? spec.mcpServers.filter((item): item is string => typeof item === 'string') + : []; + return ( + onChange('mcpServers', next)} + onClear={() => { + onChange('mcpServers', undefined); + return true; + }} + addLabel={localize('com_config_specs_mcp_add')} + unknownLabel={(name) => localize('com_config_specs_not_configured', { name })} + aria-label={label} + /> + } + /> + ); +} diff --git a/src/components/configuration/sections/specs/SpecGroup.tsx b/src/components/configuration/sections/specs/SpecGroup.tsx new file mode 100644 index 00000000..2ecac043 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecGroup.tsx @@ -0,0 +1,47 @@ +import { useContext } from 'react'; +import type * as t from '@/types'; +import { useCollapsibleSection } from '../../useCollapsibleSection'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { SettingsSection } from '../../SettingsSection'; +import { CountBadge } from '@/components/shared'; +import { isRowVisible } from '@/utils'; + +/** + * One group of the spec editor (Basics, Model, Capabilities, …): a nested + * settings group that opens for a search hit inside it and follows Expand + * all. While the tab filter is on, a group with no matching row is hidden. + */ +export function SpecGroup({ + title, + description, + paths, + defaultExpanded = false, + configuredCount = 0, + totalCount = 0, + children, +}: t.SpecGroupProps) { + const rowFilter = useContext(RowFilterContext); + const { isExpanded, hasEverExpanded, sectionRef, toggle } = useCollapsibleSection({ + defaultExpanded, + revealPaths: paths, + }); + if (!paths.some((path) => isRowVisible(rowFilter, path))) return null; + return ( + 0 && !isExpanded ? ( + + ) : undefined + } + expanded={isExpanded} + onToggle={toggle} + hasEverExpanded={hasEverExpanded} + > + {children} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecIcon.tsx b/src/components/configuration/sections/specs/SpecIcon.tsx new file mode 100644 index 00000000..cf493d70 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecIcon.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import type * as t from '@/types'; +import { cn } from '@/utils'; + +const IMAGE_URL = /^(https?:\/\/|data:image\/)/i; + +/** An absolute image URL the panel can load (a relative path resolves against LibreChat, not here). */ +export function isImageUrl(value: t.ConfigValue): value is string { + return typeof value === 'string' && IMAGE_URL.test(value.trim()); +} + +/** + * A spec's icon as the model menu shows it: the image at `iconURL`, else the + * first letter of its label (an endpoint name, a relative path or a broken + * image falls back to the letter too). Decorative: the label names the spec. + */ +export function SpecIcon({ iconURL, label, size = 'sm' }: t.SpecIconProps) { + const [failed, setFailed] = useState(null); + const src = isImageUrl(iconURL) ? iconURL.trim() : null; + const box = size === 'sm' ? 'size-5 text-[0.6875rem]' : 'size-8 text-sm'; + if (src && failed !== src) { + return ( + setFailed(src)} + className={cn(box, 'shrink-0 rounded-md object-cover')} + /> + ); + } + const initial = label.trim().charAt(0).toUpperCase() || '?'; + return ( + + ); +} diff --git a/src/components/configuration/sections/specs/SpecList.tsx b/src/components/configuration/sections/specs/SpecList.tsx new file mode 100644 index 00000000..dd6f2fb1 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecList.tsx @@ -0,0 +1,241 @@ +import { useRef, useMemo, useState, useEffect, useCallback, useContext } from 'react'; +import type * as t from '@/types'; +import { ListOwnershipNotice } from '../../renderer/Collections'; +import { ArrayObjectField } from '../../fields/ArrayObjectField'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { SpecLeading, SpecSummary, specTitle } from './SpecSummary'; +import { useLocalize, useValuesScope, useFieldState } from '@/hooks'; +import { CountBadge, DecorativeIcon } from '@/components/shared'; +import { hasPathOrDescendant } from '../../renderer/paths'; +import { SPEC_LIST_PATH } from './validation'; +import { SpecListContext } from './context'; +import { isConfigRecord } from '@/utils'; +import { SpecEditor } from './SpecEditor'; +import { SpecNote } from './SpecNote'; + +function namesOf(list: t.ConfigValue): Set { + const names = new Set(); + for (const spec of Array.isArray(list) ? list : []) { + if (isConfigRecord(spec) && typeof spec.name === 'string') names.add(spec.name); + } + return names; +} + +/** The list with one spec made the default (or soft default, or neither) and that role taken off the others. */ +export function withDefault( + items: t.ConfigValue[], + index: number, + kind: t.SpecDefaultKind, +): t.ConfigValue[] { + return items.map((spec, i) => { + if (!isConfigRecord(spec)) return spec; + const { default: isDefault, softDefault: isSoft, ...rest } = spec; + if (i === index) { + if (kind === 'default') return { ...rest, default: true }; + if (kind === 'soft') return { ...rest, softDefault: true }; + return rest; + } + if (kind === 'default' && isDefault === true) { + return { ...rest, ...(isSoft !== undefined && { softDefault: isSoft }) }; + } + if (kind === 'soft' && isSoft === true) { + return { ...rest, ...(isDefault !== undefined && { default: isDefault }) }; + } + return spec; + }); +} + +function hasPendingEdit(editedValues: t.FlatConfigMap | undefined, path: string): boolean { + return Object.keys(editedValues ?? {}).some((key) => key === path || key.startsWith(`${path}.`)); +} + +/** + * The spec list (MS-1/5/6): sortable cards (list order is menu order), each + * with its icon, label, name, endpoint · model, group and badges, opening the + * spec editor. In Base, a list the panel stores says it overrides + * librechat.yaml and offers to revert it (D2). In a profile, the list Base + * gives is shown read-only until "Customize list for …" takes a copy, since a + * profile's list replaces Base's whole. + */ +export function SpecList({ + listField, + items, + options, + problems, + onChange, + onResetField, + onDiscardField, + editedValues, + disabled, + permissions, + dbOverridePaths, + pendingResets, + editSessionId, + showConfiguredOnly, +}: t.SpecListProps) { + const localize = useLocalize(); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const listState = useFieldState(SPEC_LIST_PATH); + const addRef = useRef<(() => void) | null>(null); + const addButtonRef = useRef(null); + const [customizing, setCustomizing] = useState(false); + useEffect(() => setCustomizing(false), [editSessionId, scope?.name]); + /** "Customize list" disappears once used: focus moves on to Add spec. */ + useEffect(() => { + if (customizing) addButtonRef.current?.focus(); + }, [customizing]); + + const canEdit = !disabled && permissions?.canEdit !== false; + const isPendingReset = Boolean(pendingResets?.has(SPEC_LIST_PATH)) || listState.isPendingReset; + const ownsList = + !isPendingReset && + (hasPathOrDescendant(SPEC_LIST_PATH, dbOverridePaths) || listState.isOverride); + const isPending = hasPendingEdit(editedValues, SPEC_LIST_PATH) && !isPendingReset; + const inheritedOnly = scope != null && !ownsList && !isPending && !customizing; + const editable = canEdit && !inheritedOnly && !isPendingReset; + const savedNames = useMemo(() => namesOf(listState.effective), [listState.effective]); + + const setDefault = useCallback( + (index: number, kind: t.SpecDefaultKind) => + onChange(SPEC_LIST_PATH, withDefault(items, index, kind)), + [items, onChange], + ); + const contextValue = useMemo( + () => ({ + items, + problems, + options, + actions: { setDefault }, + savedNames, + configuredOnly: Boolean(showConfiguredOnly), + }), + [items, problems, options, setDefault, savedNames, showConfiguredOnly], + ); + const renderFields = useCallback( + (fields, value, path, change, _addField, sessionId, fieldsDisabled) => ( + + ), + [], + ); + + const scopeName = scope?.name ?? ''; + const revert = canEdit && onResetField ? () => onResetField(SPEC_LIST_PATH) : undefined; + + const notice = (() => { + if (isPendingReset) { + return ( + onDiscardField(SPEC_LIST_PATH)} + > + {localize('com_config_ui_undo')} + + ) + } + > + {localize( + scope ? 'com_config_specs_pending_revert_scope' : 'com_config_specs_pending_revert', + )} + + ); + } + if (inheritedOnly) { + return ( + setCustomizing(true)} + > + {localize('com_config_specs_customize', { name: scopeName })} + + ) + } + > + {localize('com_config_specs_inherited', { name: scopeName })} + + ); + } + if (ownsList) return ; + if (scope) { + return ( + + {localize('com_config_specs_customizing', { name: scopeName })} + + ); + } + if (isPending) { + return {localize('com_config_specs_will_own')}; + } + return null; + })(); + + const addButton = editable ? ( + + ) : undefined; + + return ( +
    +
    +

    + {localize('com_config_specs_list')} + +

    + {addButton} +

    {localize('com_config_specs_list_desc')}

    +
    + {!rowFilter && notice} + + onChange(SPEC_LIST_PATH, next)} + onEntryChange={(index, next) => onChange(`${SPEC_LIST_PATH}.${index}`, next)} + disabled={!editable} + hideAddButton + addTriggerRef={addRef} + renderFields={renderFields} + getEntryTitle={(item) => specTitle(item, localize)} + renderSummary={(item, index) => } + renderLeading={(item) => } + stackedSummary + reorderable={editable} + emptyLabel={localize( + editable ? 'com_config_specs_empty' : 'com_config_specs_empty_readonly', + )} + editSessionId={editSessionId} + /> + +
    + ); +} diff --git a/src/components/configuration/sections/specs/SpecNote.tsx b/src/components/configuration/sections/specs/SpecNote.tsx new file mode 100644 index 00000000..7d4e6141 --- /dev/null +++ b/src/components/configuration/sections/specs/SpecNote.tsx @@ -0,0 +1,36 @@ +import type * as t from '@/types'; +import { DecorativeIcon } from '@/components/shared'; +import { cn } from '@/utils'; + +const TONE_CLASSES: Record = { + info: 'settings-banner', + warning: + 'flex items-start gap-2.5 rounded-lg border border-solid border-(--settings-warn-mark) bg-(--settings-warn-bg) px-3 py-2.5 text-[0.8125rem] leading-5 text-(--settings-warn-fg)', + preview: + 'flex items-start gap-2.5 rounded-lg border border-solid border-(--cui-color-stroke-default) bg-(--cui-color-background-muted) px-3 py-2.5 text-[0.8125rem] leading-5 text-(--cui-color-text-default)', +}; + +const TONE_ICONS: Record = { + info: 'info-in-circle', + warning: 'warning', + preview: 'info-in-circle', +}; + +/** + * A note between the specs section's rows: what a list or setting means, a + * warning about a combination LibreChat handles badly, or what users will see, + * with optional actions. + */ +export function SpecNote({ tone, icon, action, children }: t.SpecNoteProps) { + return ( +
    + + {icon ?? } + +
    +

    {children}

    + {action && {action}} +
    +
    + ); +} diff --git a/src/components/configuration/sections/specs/SpecSummary.tsx b/src/components/configuration/sections/specs/SpecSummary.tsx new file mode 100644 index 00000000..2e8cc1ef --- /dev/null +++ b/src/components/configuration/sections/specs/SpecSummary.tsx @@ -0,0 +1,104 @@ +import type * as t from '@/types'; +import { isAgentSpec, problemsOf, specEndpoint } from './validation'; +import { isConfigRecord } from '@/utils'; +import { useSpecList } from './context'; +import { useLocalize } from '@/hooks'; +import { SpecIcon } from './SpecIcon'; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** A spec's display title: its label, else its name, else "New spec". */ +export function specTitle(spec: t.ConfigValue, localize: t.LocalizeFn): string { + if (!isConfigRecord(spec)) return localize('com_config_specs_new'); + return textOf(spec.label) || textOf(spec.name) || localize('com_config_specs_new'); +} + +/** The icon before a spec's title in the list. */ +export function SpecLeading({ spec }: { spec: t.ConfigValue }) { + const localize = useLocalize(); + const record = isConfigRecord(spec) ? spec : {}; + return ; +} + +function SummaryChip({ + kind, + label, + description, +}: { + kind: 'changed' | 'default' | 'hidden' | 'error' | 'warning'; + label: string; + description?: string; +}) { + return ( + + {label} + {description && {`, ${description}`}} + + ); +} + +/** + * A collapsed spec row: Default / Soft default / Hidden badges, its problems, + * then its name (when the label differs), endpoint · model and group, muted. + */ +export function SpecSummary({ spec, index }: t.SpecSummaryProps) { + const localize = useLocalize(); + const { problems } = useSpecList(); + if (!isConfigRecord(spec)) return null; + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + const own = problemsOf(problems, index); + const errors = own.filter((problem) => problem.level === 'error').length; + const warnings = own.length - errors; + const name = textOf(spec.name); + const label = textOf(spec.label); + const target = isAgentSpec(spec) ? textOf(preset.agent_id) : textOf(preset.model); + const details = [ + name && name !== label ? name : '', + [specEndpoint(spec), target].filter(Boolean).join(' · '), + textOf(spec.group) + ? localize('com_config_specs_summary_group', { group: textOf(spec.group) }) + : '', + ].filter(Boolean); + return ( + + + {spec.default === true && ( + + )} + {spec.softDefault === true && ( + + )} + {spec.showInMenu === false && ( + + )} + {errors > 0 && ( + + )} + {errors === 0 && warnings > 0 && ( + + )} + + {details.length > 0 && {details.join(' · ')}} + + ); +} diff --git a/src/components/configuration/sections/specs/SpecsSettings.tsx b/src/components/configuration/sections/specs/SpecsSettings.tsx new file mode 100644 index 00000000..bbfc04ff --- /dev/null +++ b/src/components/configuration/sections/specs/SpecsSettings.tsx @@ -0,0 +1,319 @@ +import { useContext } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { useLocalize, useFieldState, useValuesScope } from '@/hooks'; +import { RowFilterContext } from '@/contexts/RowFilterContext'; +import { DecorativeIcon, StateChip } from '@/components/shared'; +import { cn, isRowVisible, isConfigRecord } from '@/utils'; +import { hasPathOrDescendant } from '../../renderer/paths'; +import { FieldProvenance } from '../../values'; +import { SettingsRow } from '../../SettingsRow'; +import { endpointGroups } from './groups'; +import { ChipPicker } from './ChipPicker'; +import { ConfigRow } from '../../ConfigRow'; +import { SpecNote } from './SpecNote'; + +const ENFORCE = 'modelSpecs.enforce'; +const PRIORITIZE = 'modelSpecs.prioritize'; +const ADDED = 'modelSpecs.addedEndpoints'; +const MODEL_SELECT = 'interface.modelSelect'; + +const MODES: readonly t.SpecSelectionMode[] = ['enforce', 'prioritize', 'endpoints']; + +/** `enforce`/`prioritize` each mode stands for. */ +const MODE_VALUES: Record = { + enforce: { enforce: true, prioritize: true }, + prioritize: { enforce: false, prioritize: true }, + endpoints: { enforce: false, prioritize: false }, +}; + +function asBool(value: t.ConfigValue, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function stringsOf(value: t.ConfigValue): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +/** + * Whether the endpoint menu shows: `interface.modelSelect` when set, else + * what LibreChat derives with specs configured (on only with added endpoints). + */ +function modelSelectOn(value: t.ConfigValue, specCount: number, added: number): boolean { + if (typeof value === 'boolean') return value; + return specCount > 0 ? added > 0 : true; +} + +/** "Users will see: 3 specs, plus OpenAI and Agents in the endpoint menu. 1 more is hidden." */ +export function previewText( + localize: t.LocalizeFn, + { + items, + mode, + menuOn, + added, + }: { items: t.ConfigValue[]; mode: t.SpecSelectionMode; menuOn: boolean; added: string[] }, +): string { + if (items.length === 0) return localize('com_config_specs_preview_none'); + const hidden = items.filter((spec) => isConfigRecord(spec) && spec.showInMenu === false).length; + const specs = localize('com_config_specs_preview_specs', { count: items.length - hidden }); + const sentence = (() => { + if (mode === 'enforce') return localize('com_config_specs_preview_enforce', { specs }); + if (!menuOn) return localize('com_config_specs_preview_menu_off', { specs }); + if (added.length > 0) { + return localize('com_config_specs_preview_added', { specs, endpoints: added.join(', ') }); + } + return localize('com_config_specs_preview_all', { specs }); + })(); + if (hidden === 0) return sentence; + return `${sentence} ${localize('com_config_specs_preview_hidden', { count: hidden })}`; +} + +/** Enforce and Prioritize as one setting: set by mode, with their pending and overridden paths. */ +function useModeState( + onChange: t.FieldRendererProps['onChange'], + onDiscard?: (path: string) => void, +) { + const enforce = useFieldState(ENFORCE); + const prioritize = useFieldState(PRIORITIZE); + const states: Array<[string, t.FieldState]> = [ + [ENFORCE, enforce], + [PRIORITIZE, prioritize], + ]; + /** + * Sets one flag to `desired` with the fewest stored changes: back to its + * saved value drops the pending edit; back to what lies beneath removes the + * override; anything else is an edit. + */ + const apply = (path: string, state: t.FieldState, fallback: boolean, desired: boolean) => { + const saved = asBool(state.effective, fallback); + const lower = state.isOverride ? asBool(state.revertTarget?.value, fallback) : saved; + if (desired === saved && !state.isPendingReset) { + if (state.isEdited) { + if (onDiscard) onDiscard(path); + else onChange(path, saved); + } + return; + } + onChange(path, state.isOverride && desired === lower ? undefined : desired); + }; + const setMode = (mode: t.SpecSelectionMode) => { + const values = MODE_VALUES[mode]; + apply(ENFORCE, enforce, false, values.enforce); + apply(PRIORITIZE, prioritize, true, values.prioritize); + }; + const overridden = states.filter(([, state]) => state.isOverride).map(([path]) => path); + const edited = states.filter(([, state]) => state.isEdited).map(([path]) => path); + return { setMode, overridden, edited }; +} + +/** + * "How users pick models" (MS-3): Specs only, Specs first or Endpoints first + * in place of the raw Enforce and Prioritize switches; the endpoints listed + * next to the specs, picked from the configured ones; and a line saying what + * users will see. + */ +export function SpecsSettings({ + items, + options, + getValue, + onChange, + onResetField, + onDiscardField, + disabled, + permissions, + configuredPaths, + dbOverridePaths, + pendingResets, + showConfiguredOnly, +}: t.SpecsSettingsProps) { + const localize = useLocalize(); + const navigate = useNavigate(); + const scope = useValuesScope(); + const rowFilter = useContext(RowFilterContext); + const { setMode, overridden, edited } = useModeState(onChange, onDiscardField); + const enforce = asBool(getValue(ENFORCE, undefined), false); + const prioritize = asBool(getValue(PRIORITIZE, undefined), true); + const mode: t.SpecSelectionMode = (() => { + if (enforce) return 'enforce'; + return prioritize ? 'prioritize' : 'endpoints'; + })(); + const added = stringsOf(getValue(ADDED, undefined)); + const menuOn = modelSelectOn(getValue(MODEL_SELECT, undefined), items.length, added.length); + const canEdit = !disabled && permissions?.canEdit !== false; + const isShown = (path: string) => + isRowVisible(rowFilter, path) && + (!showConfiguredOnly || hasPathOrDescendant(path, configuredPaths)); + const showsMode = isShown(ENFORCE) || isShown(PRIORITIZE); + + const goToModelSelect = () => + navigate({ + to: '/configuration', + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: 'features', field: MODEL_SELECT }), + }); + const modelSelectLink = ( + + ); + + const modeChips = (): ReactNode => { + if (edited.length > 0) { + return ( + edited.forEach((path) => onDiscardField(path)) : undefined} + undoLabel={localize('com_config_ui_undo_change', { + name: localize('com_config_specs_mode'), + })} + /> + ); + } + return ; + }; + + const modeRevert = + canEdit && onResetField && overridden.length > 0 && edited.length === 0 ? ( + + ) : null; + + const addedState = useFieldState(ADDED); + const isDbOverride = hasPathOrDescendant(ADDED, dbOverridePaths); + const isConfigured = hasPathOrDescendant(ADDED, configuredPaths); + + return ( + <> + {showsMode && ( + 0 ? 'edited' : undefined} + chips={modeChips()} + actions={modeRevert} + control={ +
    + {MODES.map((value) => ( + + ))} +
    + } + /> + )} + {showsMode && enforce && !prioritize && ( + {localize('com_config_specs_mode_mixed')} + )} + {showsMode && mode === 'enforce' && menuOn && ( + + {localize('com_config_specs_enforce_menu_on')} + + )} + {isShown(ADDED) && ( + + onChange(ADDED, next)} + onClear={() => { + if (isDbOverride) { + onChange(ADDED, undefined); + return true; + } + /** librechat.yaml (or, in a profile, Base) sets the list: it can't be emptied here (D3). */ + if (addedState.effective !== undefined) return false; + onChange(ADDED, undefined); + return true; + }} + addLabel={localize('com_config_specs_added_add')} + unknownLabel={(name) => localize('com_config_specs_not_configured', { name })} + aria-label={localize('com_config_specs_added')} + /> + + )} + {isShown(ADDED) && !menuOn && added.length > 0 && mode !== 'enforce' && ( + + {localize('com_config_specs_added_menu_off')} + + )} + {!rowFilter && !showConfiguredOnly && ( + }> + {localize('com_config_specs_preview_label')}{' '} + {previewText(localize, { + items, + mode, + menuOn, + added: added.map( + (name) => options.endpoints.find((option) => option.value === name)?.label ?? name, + ), + })} + + )} + + ); +} diff --git a/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx new file mode 100644 index 00000000..c898f5b7 --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/ModelSpecsRenderer.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, vi } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import type * as t from '@/types'; +import { + baseConfigFixture, + baseLayersFixture, + scopeLayersFixture, + valuesDataFixture, +} from '@/test/layers'; +import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; +import { ModelSpecsRenderer } from '../ModelSpecsRenderer'; +import { extractSchemaTree } from '@/server/config'; +import { previewText } from '../SpecsSettings'; +import { slugify } from '../SpecFields'; +import { withDefault } from '../SpecList'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + useLocalize: () => (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, +})); + +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => vi.fn(), +})); + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const specFields = tree.find((field) => field.key === 'modelSpecs')?.children ?? []; + +const SPECS: t.ConfigRecord[] = [ + { + name: 'gpt', + label: 'GPT', + default: true, + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + }, + { + name: 'claude', + label: 'Claude', + showInMenu: false, + preset: { endpoint: 'anthropic', model: 'claude-sonnet-4-5' }, + }, +]; + +function renderSpecs( + modelSpecs: t.ConfigRecord, + { + onChange = vi.fn(), + data, + }: { onChange?: t.FieldRendererProps['onChange']; data?: t.ConfigValuesData } = {}, +) { + const values = data ?? valuesDataFixture(baseLayersFixture()); + const wrap = (children: ReactNode) => ( + + {children} + + ); + const ui = ( + { + const key = path.slice('modelSpecs.'.length); + return key in modelSpecs ? modelSpecs[key] : fallback; + }} + onChange={onChange} + onResetField={vi.fn()} + onDiscardField={vi.fn()} + permissions={{ canView: true, canEdit: true, canAssign: true }} + /> + ); + return { onChange, ...render(wrap(ui)) }; +} + +describe('ModelSpecsRenderer', () => { + it('lists each spec by label with its badges and endpoint · model', () => { + renderSpecs({ list: SPECS }); + const cards = document.querySelectorAll('[data-entry-card]'); + expect(cards).toHaveLength(2); + expect(within(cards[0] as HTMLElement).getByText('GPT')).toBeInTheDocument(); + expect( + within(cards[0] as HTMLElement).getByText('com_config_specs_badge_default'), + ).toBeInTheDocument(); + expect(within(cards[0] as HTMLElement).getByText('gpt · openAI · gpt-4o')).toBeInTheDocument(); + expect( + within(cards[1] as HTMLElement).getByText('com_config_specs_badge_hidden'), + ).toBeInTheDocument(); + }); + + it('opens a new spec with Endpoint and Model up front, marked required', () => { + const { onChange } = renderSpecs({ list: SPECS }); + fireEvent.click(screen.getByRole('button', { name: 'com_config_specs_add' })); + expect(onChange).toHaveBeenCalledWith('modelSpecs.list', [...SPECS, {}]); + }); + + it('shows the endpoint as a choice of configured endpoints and the model as text', () => { + renderSpecs({ list: SPECS }); + const toggle = document.querySelectorAll( + '[data-entry-card] button[aria-expanded]', + )[0]; + fireEvent.click(toggle); + const endpoint = document.getElementById( + 'modelSpecs-list-0-preset-endpoint', + ) as HTMLSelectElement; + expect(endpoint.tagName).toBe('SELECT'); + expect(endpoint.value).toBe('openAI'); + const values = [...endpoint.querySelectorAll('option')].map((option) => option.value); + expect(values).toEqual( + expect.arrayContaining(['openAI', 'anthropic', 'agents', 'GPT', 'Claude']), + ); + expect(values).not.toContain('custom'); + expect( + (document.getElementById('modelSpecs-list-0-preset-model') as HTMLInputElement).value, + ).toBe('gpt-4o'); + }); + + it('moves the default to the spec it is set on (one default per list)', () => { + expect( + withDefault(SPECS, 1, 'default').map((spec) => (spec as t.ConfigRecord).default), + ).toEqual([undefined, true]); + const soft = withDefault( + [{ name: 'a', softDefault: true, default: true }, { name: 'b' }], + 1, + 'soft', + ); + expect(soft).toEqual([ + { name: 'a', default: true }, + { name: 'b', softDefault: true }, + ]); + expect(withDefault(SPECS, 0, 'none')[0]).not.toHaveProperty('default'); + const explicit = [{ name: 'a', default: false }, { name: 'b' }]; + expect(withDefault(explicit, 1, 'default')[0]).toBe(explicit[0]); + }); + + it('maps "How users pick models" onto enforce and prioritize', () => { + const { onChange } = renderSpecs({ list: SPECS, enforce: false, prioritize: true }); + expect(screen.getByLabelText('com_config_specs_mode_prioritize')).toBeChecked(); + fireEvent.click(screen.getByLabelText('com_config_specs_mode_enforce')); + expect(onChange).toHaveBeenCalledWith('modelSpecs.enforce', true); + expect(onChange).not.toHaveBeenCalledWith('modelSpecs.prioritize', expect.anything()); + fireEvent.click(screen.getByLabelText('com_config_specs_mode_endpoints')); + expect(onChange).toHaveBeenCalledWith('modelSpecs.prioritize', false); + }); + + it('adds endpoints next to the specs only from the configured ones (no blank or typed names)', () => { + const { onChange } = renderSpecs({ list: SPECS, addedEndpoints: ['openAI', 'Gone'] }); + const picker = document.getElementById('modelSpecs-addedEndpoints-add') as HTMLSelectElement; + const options = [...picker.querySelectorAll('option')].map((option) => option.value); + expect(options).not.toContain(''); + expect(options).not.toContain('openAI'); + expect(options).toContain('agents'); + expect(screen.getByText('com_config_specs_not_configured {"name":"Gone"}')).toBeInTheDocument(); + fireEvent.change(picker, { target: { value: 'agents' } }); + expect(onChange).toHaveBeenCalledWith('modelSpecs.addedEndpoints', [ + 'openAI', + 'Gone', + 'agents', + ]); + fireEvent.click( + screen.getByRole('button', { name: /com_config_specs_chip_remove \{"name":"Gone"\}/ }), + ); + expect(onChange).toHaveBeenCalledWith('modelSpecs.addedEndpoints', ['openAI']); + }); + + it('flags spec problems on the row and at the field', () => { + renderSpecs({ + list: [...SPECS, { name: 'gpt', label: 'Copy', preset: { endpoint: 'Nope', model: 'm' } }], + }); + const card = document.querySelectorAll('[data-entry-card]')[2]; + expect(within(card).getByText('com_config_specs_badge_errors {"count":1}')).toBeInTheDocument(); + fireEvent.click(within(card).getAllByRole('button', { expanded: false })[0]); + expect( + within(card).getByText(/com_config_issue_spec_duplicate_name/, { + selector: '[role="alert"]', + }), + ).toBeInTheDocument(); + expect(within(card).getByText(/com_config_issue_spec_unknown_endpoint/)).toBeInTheDocument(); + }); + + it('shows the inherited list read-only in a profile until it is customized', () => { + const base = baseConfigFixture({ dbOverrides: { modelSpecs: { list: SPECS } } }); + const data = valuesDataFixture(scopeLayersFixture(undefined, base)); + renderSpecs({ list: SPECS }, { data }); + expect(screen.queryByRole('button', { name: 'com_config_specs_add' })).not.toBeInTheDocument(); + expect(screen.queryAllByRole('button', { name: /com_config_ctl_entry_move/ })).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: /com_config_specs_customize/ })); + expect(screen.getByRole('button', { name: 'com_config_specs_add' })).toHaveFocus(); + expect(screen.getByText(/com_config_specs_customizing/)).toBeInTheDocument(); + }); + + it('says an inherited endpoint list cannot be emptied in a profile (D3)', () => { + const base = baseConfigFixture({ dbOverrides: { modelSpecs: { addedEndpoints: ['openAI'] } } }); + const data = valuesDataFixture(scopeLayersFixture(undefined, base)); + const { onChange } = renderSpecs({ list: SPECS, addedEndpoints: ['openAI'] }, { data }); + fireEvent.click(screen.getByRole('button', { name: /com_config_specs_chip_remove/ })); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText('com_config_ctl_clear_unavailable')).toBeInTheDocument(); + }); + + it('offers an Anthropic spec only Anthropic parameters', () => { + renderSpecs({ list: SPECS }); + const card = document.querySelectorAll('[data-entry-card]')[1]; + fireEvent.click(within(card).getAllByRole('button', { expanded: false })[0]); + fireEvent.click(within(card).getByRole('button', { name: /com_config_specs_group_params/ })); + expect(document.getElementById('modelSpecs-list-1-preset-topK')).not.toBeNull(); + expect(document.getElementById('modelSpecs-list-1-preset-top_p')).toBeNull(); + expect(document.getElementById('modelSpecs-list-1-preset-frequency_penalty')).toBeNull(); + }); +}); + +describe('spec helpers', () => { + it('derives a unique name from a label', () => { + expect(slugify('GPT 5 mini!', new Set())).toBe('gpt-5-mini'); + expect(slugify('GPT', new Set(['gpt', 'gpt-2']))).toBe('gpt-3'); + expect(slugify(' ', new Set())).toBe('spec'); + }); + + it('says what users will see', () => { + const localize: t.LocalizeFn = (key, params) => `${key}${params ? JSON.stringify(params) : ''}`; + const items = [{ name: 'a' }, { name: 'b', showInMenu: false }]; + expect( + previewText(localize, { items, mode: 'prioritize', menuOn: true, added: ['OpenAI'] }), + ).toBe( + 'com_config_specs_preview_added{"specs":"com_config_specs_preview_specs{\\"count\\":1}","endpoints":"OpenAI"} com_config_specs_preview_hidden{"count":1}', + ); + expect(previewText(localize, { items: [], mode: 'enforce', menuOn: true, added: [] })).toBe( + 'com_config_specs_preview_none', + ); + expect(previewText(localize, { items, mode: 'enforce', menuOn: true, added: [] })).toMatch( + /^com_config_specs_preview_enforce/, + ); + }); +}); diff --git a/src/components/configuration/sections/specs/__tests__/params.test.ts b/src/components/configuration/sections/specs/__tests__/params.test.ts new file mode 100644 index 00000000..225eeccc --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/params.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { configSchema } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { + PRESET_DENYLIST, + paramField, + presetParams, + promptKeyFor, + specSearchFields, +} from '../params'; +import { extractSchemaTree } from '@/server/config'; + +const tree = extractSchemaTree(configSchema as t.ZodSchemaLike); +const presetField = tree + .find((field) => field.key === 'modelSpecs') + ?.children?.find((field) => field.key === 'list') + ?.children?.find((field) => field.key === 'preset'); +const presetKeys = new Set((presetField?.children ?? []).map((field) => field.key)); + +function keysOf(set: t.PresetParamSet): string[] { + return set.params.map((param) => param.key); +} + +describe('presetParams', () => { + it('offers an Anthropic spec only Anthropic parameters', () => { + const anthropic = presetParams('anthropic', 'claude-sonnet-4-5', undefined, presetKeys); + expect(anthropic.settingsKey).toBe('anthropic'); + const keys = keysOf(anthropic); + expect(keys).toEqual(expect.arrayContaining(['maxOutputTokens', 'topP', 'topK', 'thinking'])); + expect(keys).not.toEqual(expect.arrayContaining(['top_p'])); + expect( + keys.filter((key) => + ['max_tokens', 'top_p', 'frequency_penalty', 'reasoning_effort'].includes(key), + ), + ).toEqual([]); + }); + + it('leaves the Model and Prompt fields to their own groups', () => { + const keys = keysOf(presetParams('openAI', 'gpt-4o', undefined, presetKeys)); + expect(keys).toEqual(expect.arrayContaining(['temperature', 'top_p', 'max_tokens', 'stop'])); + for (const key of ['modelLabel', 'promptPrefix', 'model', 'endpoint']) { + expect(keys).not.toContain(key); + } + }); + + it('resolves the model family (Bedrock) and a custom endpoint’s parameter set', () => { + expect(presetParams('bedrock', 'anthropic.claude-3', undefined, presetKeys).settingsKey).toBe( + 'bedrock-anthropic', + ); + expect(presetParams('Azure Foundry', 'gpt-5', undefined, presetKeys).settingsKey).toBe( + 'custom', + ); + const viaAnthropic = presetParams( + 'Claude Proxy', + 'claude', + { name: 'Claude Proxy', customParams: { defaultParamsEndpoint: 'anthropic' } }, + presetKeys, + ); + expect(viaAnthropic.settingsKey).toBe('anthropic'); + }); + + it('has no parameters for agents or without an endpoint', () => { + expect(presetParams('agents', '', undefined, presetKeys).params).toEqual([]); + expect(presetParams('', '', undefined, presetKeys)).toEqual({ settingsKey: null, params: [] }); + }); + + it('keeps ranges, defaults and real choices (the empty “auto” choice is the default)', () => { + const params = presetParams('openAI', 'gpt-4o', undefined, presetKeys).params; + const temperature = params.find((param) => param.key === 'temperature'); + expect(temperature).toMatchObject({ kind: 'number', min: 0, max: 2, defaultValue: 1 }); + const effort = params.find((param) => param.key === 'reasoning_effort'); + expect(effort?.kind).toBe('enum'); + expect(effort?.options).not.toContain(''); + expect(effort?.defaultValue).toBeUndefined(); + expect(params.find((param) => param.key === 'stop')).toMatchObject({ kind: 'list' }); + }); + + it('never offers internal conversation fields', () => { + for (const endpoint of ['openAI', 'anthropic', 'google', 'bedrock', 'Azure Foundry']) { + const keys = keysOf(presetParams(endpoint, 'm', undefined, presetKeys)); + expect(keys.filter((key) => PRESET_DENYLIST.has(key))).toEqual([]); + } + }); +}); + +describe('promptKeyFor', () => { + it('uses the system prompt where the parameter panel does (Bedrock Anthropic models)', () => { + expect(promptKeyFor('bedrock', 'anthropic.claude-3')).toBe('system'); + expect(promptKeyFor('openAI', 'gpt-4o')).toBe('promptPrefix'); + expect(promptKeyFor('', '')).toBe('promptPrefix'); + }); +}); + +describe('paramField', () => { + const field: t.SchemaField = { + path: 'p', + key: 'temperature', + type: 'number', + isOptional: true, + depth: 0, + }; + + it('adds the range to a number and the choices to an enum', () => { + expect( + paramField(field, { key: 'temperature', kind: 'number', min: 0, max: 2, step: 0.01 }) + .constraints, + ).toEqual({ + min: 0, + max: 2, + step: 0.01, + }); + expect( + paramField( + { ...field, type: 'string' }, + { key: 'effort', kind: 'enum', options: ['low', 'high'] }, + ).type, + ).toBe('enum(low | high)'); + expect(paramField(field, { key: 'x', kind: 'text' })).toBe(field); + }); +}); + +describe('specSearchFields', () => { + const specFields = + tree.find((field) => field.key === 'modelSpecs')?.children?.find((f) => f.key === 'list') + ?.children ?? []; + + function presetKeysFor(spec: t.ConfigValue, root: t.ConfigValue = null): string[] { + const preset = specSearchFields(specFields, spec, root).find((field) => field.key === 'preset'); + return (preset?.children ?? []).map((field) => field.key); + } + + it('indexes only the parameters the spec’s endpoint offers, plus what it sets', () => { + const openAI = presetKeysFor({ preset: { endpoint: 'openAI', model: 'gpt-4o', topK: 3 } }); + expect(openAI).toEqual(expect.arrayContaining(['endpoint', 'model', 'temperature', 'topK'])); + expect(openAI).not.toContain('thinkingBudget'); + expect(openAI).not.toContain('archivedAt'); + }); + + it('follows a custom endpoint’s parameter set', () => { + const root = { + endpoints: { + custom: [{ name: 'Proxy', customParams: { defaultParamsEndpoint: 'anthropic' } }], + }, + }; + expect(presetKeysFor({ preset: { endpoint: 'Proxy', model: 'x' } }, root)).toContain( + 'thinkingBudget', + ); + }); +}); diff --git a/src/components/configuration/sections/specs/__tests__/validation.test.ts b/src/components/configuration/sections/specs/__tests__/validation.test.ts new file mode 100644 index 00000000..69adfd1d --- /dev/null +++ b/src/components/configuration/sections/specs/__tests__/validation.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import type * as t from '@/types'; +import { specListIssues, validateSpecs, specEndpoint, problemsOf } from '../validation'; +import { getKeyedCollection } from '@/utils'; + +const known = new Set(['openAI', 'anthropic', 'agents', 'Azure Foundry']); + +function spec(name: string, extra: t.ConfigRecord = {}): t.ConfigRecord { + return { + name, + label: name.toUpperCase(), + preset: { endpoint: 'openAI', model: 'gpt-4o' }, + ...extra, + }; +} + +function codes(problems: t.SpecProblem[]): string[] { + return problems.map((problem) => `${problem.index}:${problem.field}:${problem.code}`); +} + +describe('validateSpecs', () => { + it('accepts a complete list', () => { + expect(validateSpecs([spec('a'), spec('b')], { knownEndpoints: known })).toEqual([]); + }); + + it('requires a name, label, endpoint and model on every spec', () => { + const problems = validateSpecs([{ preset: {} }, { name: ' ', label: 'X' }], { + knownEndpoints: known, + }); + expect(codes(problems)).toEqual([ + '0:name:required', + '0:label:required', + '0:preset.endpoint:required', + '0:preset.model:required', + '1:name:required', + '1:preset.endpoint:required', + '1:preset.model:required', + ]); + }); + + it('asks an agent spec for its agent, not a model, and infers the agents endpoint', () => { + const agent = { name: 'a', label: 'A', preset: { agent_id: 'agent_123' } }; + expect(specEndpoint(agent)).toBe('agents'); + expect(validateSpecs([agent], { knownEndpoints: known })).toEqual([]); + const missing = { name: 'b', label: 'B', preset: { endpoint: 'agents' } }; + expect(codes(validateSpecs([missing], { knownEndpoints: known }))).toEqual([ + '0:preset.agent_id:required', + ]); + }); + + it('flags every spec after the first that repeats a name, a default or a soft default', () => { + const items = [ + spec('a', { default: true, softDefault: true }), + spec('a', { default: true }), + spec('c', { softDefault: true }), + ]; + const problems = validateSpecs(items, { knownEndpoints: known }); + expect(codes(problems)).toEqual([ + '1:name:spec_duplicate_name', + '1:default:spec_multiple_default', + '2:softDefault:spec_multiple_soft_default', + ]); + expect(problems[0].params).toEqual({ name: 'A', position: 1 }); + }); + + it('warns, without blocking, about an endpoint the configuration does not define', () => { + const items = [spec('a', { preset: { endpoint: 'Missing', model: 'm' } })]; + const problems = validateSpecs(items, { knownEndpoints: known }); + expect(problems).toEqual([ + { + index: 0, + field: 'preset.endpoint', + code: 'spec_unknown_endpoint', + level: 'warning', + params: { endpoint: 'Missing' }, + }, + ]); + expect(validateSpecs(items, { knownEndpoints: new Set() })).toEqual([]); + }); + + it('lists a spec’s errors before its warnings', () => { + const items = [{ name: 'a', preset: { endpoint: 'Missing', model: 'm' } }]; + const problems = problemsOf(validateSpecs(items, { knownEndpoints: known }), 0); + expect(problems.map((problem) => problem.level)).toEqual(['error', 'warning']); + }); +}); + +describe('specListIssues', () => { + const collection = getKeyedCollection('modelSpecs.list')!; + + function changeSet(items: t.ConfigValue[]): t.ConfigChangeSet { + return { + ops: [], + issues: [], + payload: { touched: [], saves: [], resets: [], skipped: [] }, + collections: [{ collection, items, edit: { baseline: [], origins: [] } }], + }; + } + + it('blocks Save on spec errors at their pending position, named by a unique spec name', () => { + const issues = specListIssues( + changeSet([spec('a'), spec('b', { label: '', default: true }), spec('c', { default: true })]), + ); + expect(issues).toEqual([ + expect.objectContaining({ + path: 'modelSpecs.list.1.label', + code: 'required', + entryIdentity: 'b', + entryPath: 'label', + }), + expect.objectContaining({ + path: 'modelSpecs.list.2.default', + code: 'spec_multiple_default', + params: { name: 'b', position: 2 }, + }), + ]); + }); + + it('leaves names to the pipeline and ignores warnings and untouched lists', () => { + expect( + specListIssues(changeSet([{ label: 'X', preset: { endpoint: 'x', model: 'm' } }])), + ).toEqual([]); + expect(specListIssues(changeSet([spec('a'), spec('a')]))).toEqual([]); + expect(specListIssues({ ...changeSet([]), collections: [] })).toEqual([]); + }); +}); diff --git a/src/components/configuration/sections/specs/context.ts b/src/components/configuration/sections/specs/context.ts new file mode 100644 index 00000000..3bd9d265 --- /dev/null +++ b/src/components/configuration/sections/specs/context.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; +import type * as t from '@/types'; + +/** The spec list an editor or summary belongs to (its items, problems, options and actions). */ +export const SpecListContext = createContext(null); + +export function useSpecList(): t.SpecListContextValue { + const value = useContext(SpecListContext); + if (!value) throw new Error('useSpecList must be used inside a spec list'); + return value; +} + +/** Position of a spec from its data path (`modelSpecs.list.3` → 3). */ +export function specIndexOf(path: string): number { + return Number(path.slice(path.lastIndexOf('.') + 1)); +} diff --git a/src/components/configuration/sections/specs/groups.ts b/src/components/configuration/sections/specs/groups.ts new file mode 100644 index 00000000..d9e47bca --- /dev/null +++ b/src/components/configuration/sections/specs/groups.ts @@ -0,0 +1,19 @@ +import type * as t from '@/types'; + +/** Endpoint choices grouped: built-in providers, then custom endpoints. */ +export function endpointGroups( + options: t.SpecOptions, + localize: t.LocalizeFn, +): t.SpecOptionGroup[] { + const builtIn = options.endpoints.filter((option) => !options.customEndpoints.has(option.value)); + const custom = options.endpoints.filter((option) => options.customEndpoints.has(option.value)); + return [ + { label: localize('com_config_specs_endpoints_builtin'), options: builtIn }, + { label: localize('com_config_specs_endpoints_custom'), options: custom }, + ]; +} + +/** One group of MCP server choices. */ +export function mcpGroups(options: t.SpecOptions, localize: t.LocalizeFn): t.SpecOptionGroup[] { + return [{ label: localize('com_config_specs_mcp_servers'), options: options.mcpServers }]; +} diff --git a/src/components/configuration/sections/specs/index.ts b/src/components/configuration/sections/specs/index.ts new file mode 100644 index 00000000..3dda706d --- /dev/null +++ b/src/components/configuration/sections/specs/index.ts @@ -0,0 +1,7 @@ +/** + * Model specs tab (WS11): how users pick models, the spec list (sortable, + * list order = menu order) and a purpose-built spec editor with semantic + * validation and endpoint-aware model parameters. + */ +export { ModelSpecsRenderer } from './ModelSpecsRenderer'; +export { specListIssues } from './validation'; diff --git a/src/components/configuration/sections/specs/options.ts b/src/components/configuration/sections/specs/options.ts new file mode 100644 index 00000000..025ca2d9 --- /dev/null +++ b/src/components/configuration/sections/specs/options.ts @@ -0,0 +1,124 @@ +import { useContext, useMemo } from 'react'; +import type * as t from '@/types'; +import { ConfigValuesContext } from '@/contexts/ConfigValuesContext'; +import { isConfigRecord, getValueAtPath } from '@/utils'; +import { useLocalize, useOptionSources } from '@/hooks'; +import { BUILT_IN_ENDPOINTS, specEndpoint } from './validation'; +import { labelForPath } from '../../meta'; + +function stringsOf(value: t.ConfigValue): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); +} + +/** Options with duplicate values dropped (the first label wins). */ +export function uniqueOptions(options: t.SelectOption[]): t.SelectOption[] { + const seen = new Set(); + return options.filter((option) => { + if (seen.has(option.value)) return false; + seen.add(option.value); + return true; + }); +} + +function customEndpointsOf(root: t.ConfigValue): Map { + const custom = getValueAtPath(root, 'endpoints.custom'); + const byName = new Map(); + for (const entry of Array.isArray(custom) ? custom : []) { + if (isConfigRecord(entry) && typeof entry.name === 'string' && entry.name.trim() !== '') { + byName.set(entry.name, entry); + } + } + return byName; +} + +/** + * Endpoint choices for a spec: the built-in providers, then the custom + * endpoints the configuration defines, then any WS9 option source adds. + */ +export function endpointOptions( + customNames: Iterable, + localize: t.LocalizeFn, + extra: t.SelectOption[] = [], +): t.SelectOption[] { + return uniqueOptions([ + ...BUILT_IN_ENDPOINTS.map((name) => ({ + value: name, + label: labelForPath(`endpoints.${name}`, localize), + })), + ...[...customNames].map((name) => ({ value: name, label: name })), + ...extra, + ]); +} + +/** Models to suggest for an endpoint: a custom endpoint's model list, then models other specs use with it. */ +export function modelSuggestions( + endpoint: string, + custom: t.ConfigRecord | undefined, + items: t.ConfigValue[], +): t.SelectOption[] { + const listed = isConfigRecord(custom?.models) ? stringsOf(custom.models.default) : []; + const used = items.flatMap((spec) => { + if (!isConfigRecord(spec) || specEndpoint(spec) !== endpoint) return []; + const preset = isConfigRecord(spec.preset) ? spec.preset : {}; + return typeof preset.model === 'string' && preset.model.trim() !== '' ? [preset.model] : []; + }); + return uniqueOptions([...listed, ...used].map((model) => ({ value: model, label: model }))); +} + +/** Group names the specs use, then endpoint names (a group named after an endpoint nests under it). */ +export function groupOptions( + items: t.ConfigValue[], + endpoints: t.SelectOption[], +): t.SelectOption[] { + const groups = items.flatMap((spec) => + isConfigRecord(spec) && typeof spec.group === 'string' && spec.group.trim() !== '' + ? [{ value: spec.group, label: spec.group }] + : [], + ); + return uniqueOptions([...groups, ...endpoints]); +} + +/** + * Everything the spec editor offers to pick from, read from the configuration + * shown on the page (pending edits included) and merged with WS9's + * `useOptionSources` where it provides more. + */ +export function useSpecOptions(items: t.ConfigValue[]): t.SpecOptions { + const localize = useLocalize(); + const root = useContext(ConfigValuesContext)?.configValues ?? null; + const endpointSource = useOptionSources('endpoints'); + const mcpSource = useOptionSources('mcpServers'); + const customEndpoints = useMemo(() => customEndpointsOf(root), [root]); + const mcpRecord = getValueAtPath(root, 'mcpServers'); + + const endpoints = useMemo( + () => endpointOptions(customEndpoints.keys(), localize, endpointSource.options), + [customEndpoints, localize, endpointSource.options], + ); + const mcpServers = useMemo( + () => + uniqueOptions([ + ...Object.keys(isConfigRecord(mcpRecord) ? mcpRecord : {}).map((name) => ({ + value: name, + label: name, + })), + ...mcpSource.options, + ]), + [mcpRecord, mcpSource.options], + ); + + return useMemo( + () => ({ + endpoints, + knownEndpoints: new Set(endpoints.map((option) => option.value)), + customEndpoints: new Set(customEndpoints.keys()), + mcpServers, + groups: groupOptions(items, endpoints), + modelsFor: (endpoint: string) => + modelSuggestions(endpoint, customEndpoints.get(endpoint), items), + customEndpoint: (name: string) => customEndpoints.get(name), + }), + [endpoints, customEndpoints, mcpServers, items], + ); +} diff --git a/src/components/configuration/sections/specs/params.ts b/src/components/configuration/sections/specs/params.ts new file mode 100644 index 00000000..85a2854a --- /dev/null +++ b/src/components/configuration/sections/specs/params.ts @@ -0,0 +1,170 @@ +import { EModelEndpoint, paramSettings, getSettingsKeys } from 'librechat-data-provider'; +import type { SettingDefinition } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { isConfigRecord, getValueAtPath } from '@/utils'; + +/** + * Preset keys that are conversation state or internal plumbing, never + * something a spec should set (or that clash with a spec-level field of the + * same name: the spec's own `iconURL` and `artifacts` are the ones used). + */ +export const PRESET_DENYLIST: ReadonlySet = new Set([ + 'archivedAt', + 'pinned', + 'isShared', + 'isTemporary', + 'tools', + 'subagentThread', + 'codeWorkspaces', + 'endpointType', + 'stream', + 'iconURL', + 'artifacts', + 'userLabel', +]); + +/** Preset keys the editor shows in its own groups (Model, Prompt), not among the parameters. */ +export const MODEL_KEYS: readonly string[] = ['endpoint', 'model', 'modelLabel', 'agent_id']; +export const PROMPT_KEYS: readonly string[] = [ + 'promptPrefix', + 'system', + 'instructions', + 'additional_instructions', + 'greeting', +]; +const GROUPED_KEYS: ReadonlySet = new Set([...MODEL_KEYS, ...PROMPT_KEYS]); + +const BUILT_IN: ReadonlySet = new Set(Object.values(EModelEndpoint)); + +function paramKind(setting: SettingDefinition): t.PresetParamKind | null { + if (setting.type === 'boolean') return 'boolean'; + if (setting.type === 'number') return 'number'; + if (setting.type === 'array') return 'list'; + if (setting.type === 'enum') return 'enum'; + if (setting.type === 'string') return setting.component === 'textarea' ? 'textarea' : 'text'; + return null; +} + +function toParam(setting: SettingDefinition): t.PresetParam | null { + const kind = paramKind(setting); + if (!kind) return null; + const param: t.PresetParam = { key: setting.key, kind }; + if (setting.range) { + param.min = setting.range.min; + param.max = setting.range.max; + if (setting.range.step !== undefined) param.step = setting.range.step; + } + if (kind === 'enum' && setting.options) { + param.options = setting.options.filter((option) => option !== ''); + } + const fallback = setting.default; + const isEmptyDefault = fallback === '' || (Array.isArray(fallback) && fallback.length === 0); + if (fallback !== undefined && !isEmptyDefault) param.defaultValue = fallback; + return param; +} + +/** + * The endpoint type LibreChat resolves parameters by: a built-in provider is + * its own type; any other name is a custom endpoint, whose parameters follow + * its `customParams.defaultParamsEndpoint` when set. + */ +function settingsEndpoint(endpoint: string, custom: t.ConfigRecord | undefined): string { + if (BUILT_IN.has(endpoint)) return endpoint; + const customParams = isConfigRecord(custom?.customParams) ? custom.customParams : undefined; + const defaultParams = customParams?.defaultParamsEndpoint; + return typeof defaultParams === 'string' && defaultParams !== '' + ? defaultParams + : EModelEndpoint.custom; +} + +/** + * The model parameters a spec's preset can set for its endpoint and model, + * from LibreChat's own parameter panel settings (`paramSettings`): the model + * family key first (`bedrock-anthropic`), else the endpoint's. The Model and + * Prompt fields are left out (the editor shows them in their own groups), and + * so are parameters the preset schema can't store. + */ +export function presetParams( + endpoint: string, + model: string, + custom: t.ConfigRecord | undefined, + presetKeys: ReadonlySet, +): t.PresetParamSet { + if (!endpoint) return { settingsKey: null, params: [] }; + const type = settingsEndpoint(endpoint, custom); + const [combinedKey, endpointKey] = getSettingsKeys(type, model); + const settingsKey = paramSettings[combinedKey] ? combinedKey : endpointKey; + const settings = paramSettings[settingsKey]; + if (!settings) return { settingsKey: null, params: [] }; + const params = settings.flatMap((setting) => { + if (GROUPED_KEYS.has(setting.key) || !presetKeys.has(setting.key)) return []; + const param = toParam(setting); + return param ? [param] : []; + }); + return { settingsKey, params }; +} + +/** The prompt field an endpoint's parameter panel uses (`system` for Bedrock's Anthropic models). */ +export function promptKeyFor(endpoint: string, model: string, custom?: t.ConfigRecord): string { + if (!endpoint) return 'promptPrefix'; + const type = settingsEndpoint(endpoint, custom); + const [combinedKey, endpointKey] = getSettingsKeys(type, model); + const settings = paramSettings[combinedKey] ?? paramSettings[endpointKey] ?? []; + return settings.some((setting) => setting.key === 'system') ? 'system' : 'promptPrefix'; +} + +/** A schema field with a parameter's range and choices, so the shared control enforces them. */ +export function paramField(field: t.SchemaField, param: t.PresetParam): t.SchemaField { + if (param.kind === 'enum' && param.options?.length) { + return { ...field, type: `enum(${param.options.join(' | ')})` }; + } + if (param.kind !== 'number' || (param.min === undefined && param.max === undefined)) { + return field; + } + return { + ...field, + constraints: { + ...field.constraints, + ...(param.min !== undefined && { min: param.min }), + ...(param.max !== undefined && { max: param.max }), + ...(param.step !== undefined && { step: param.step }), + }, + }; +} + +/** A custom endpoint's settings by name, from the whole configuration. */ +function customEndpointOf(root: t.ConfigValue, name: string): t.ConfigRecord | undefined { + const custom = getValueAtPath(root, 'endpoints.custom'); + const entry = Array.isArray(custom) + ? custom.find((item) => isConfigRecord(item) && item.name === name) + : undefined; + return isConfigRecord(entry) ? entry : undefined; +} + +/** + * A spec's fields as its editor shows them, for search: the preset narrowed + * to the Model and Prompt fields, its endpoint's parameters, and whatever + * else the preset sets (so "Thinking budget" isn't found on an OpenAI spec). + */ +export function specSearchFields( + fields: t.SchemaField[], + spec: t.ConfigValue, + root: t.ConfigValue, +): t.SchemaField[] { + const preset = isConfigRecord(spec) && isConfigRecord(spec.preset) ? spec.preset : {}; + return fields.map((field) => { + if (field.key !== 'preset' || !field.children) return field; + const presetKeys = new Set(field.children.map((child) => child.key)); + const endpoint = typeof preset.endpoint === 'string' ? preset.endpoint : ''; + const model = typeof preset.model === 'string' ? preset.model : ''; + const shown = new Set([ + ...MODEL_KEYS, + ...PROMPT_KEYS, + ...presetParams(endpoint, model, customEndpointOf(root, endpoint), presetKeys).params.map( + (param) => param.key, + ), + ...Object.keys(preset), + ]); + return { ...field, children: field.children.filter((child) => shown.has(child.key)) }; + }); +} diff --git a/src/components/configuration/sections/specs/validation.ts b/src/components/configuration/sections/specs/validation.ts new file mode 100644 index 00000000..af70c55a --- /dev/null +++ b/src/components/configuration/sections/specs/validation.ts @@ -0,0 +1,189 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { isConfigRecord, getKeyedCollection } from '@/utils'; + +export const SPEC_LIST_PATH = 'modelSpecs.list'; + +/** Built-in providers a spec can name (`custom` is a type, not an endpoint). */ +export const BUILT_IN_ENDPOINTS: readonly string[] = Object.values(EModelEndpoint).filter( + (endpoint) => endpoint !== EModelEndpoint.custom, +); + +/** English fallback of each issue code (the page shows `com_config_issue_`). */ +const ISSUE_TEXT: Record = { + required: 'is required', + spec_multiple_default: 'Only one spec can be the default', + spec_multiple_soft_default: 'Only one spec can be the soft default', +}; + +function textOf(value: t.ConfigValue): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function presetOf(spec: t.ConfigRecord): t.ConfigRecord { + return isConfigRecord(spec.preset) ? spec.preset : {}; +} + +/** + * The endpoint a spec uses, as LibreChat resolves it: the preset's endpoint, + * or the agents endpoint when the preset names only an agent. + */ +export function specEndpoint(spec: t.ConfigValue): string { + if (!isConfigRecord(spec)) return ''; + const preset = presetOf(spec); + const endpoint = textOf(preset.endpoint); + if (endpoint) return endpoint; + return textOf(preset.agent_id) ? EModelEndpoint.agents : ''; +} + +/** Whether a spec runs an agent (the agents endpoint picks its model from the agent). */ +export function isAgentSpec(spec: t.ConfigValue): boolean { + return specEndpoint(spec) === EModelEndpoint.agents; +} + +function requiredProblems(spec: t.ConfigRecord, index: number): t.SpecProblem[] { + const preset = presetOf(spec); + const problems: t.SpecProblem[] = []; + const required = (field: string) => + problems.push({ index, field, code: 'required', level: 'error' }); + if (!textOf(spec.name)) required('name'); + if (!textOf(spec.label)) required('label'); + const endpoint = specEndpoint(spec); + if (!endpoint) required('preset.endpoint'); + if (endpoint === EModelEndpoint.agents) { + if (!textOf(preset.agent_id)) required('preset.agent_id'); + } else if (!textOf(preset.model)) { + required('preset.model'); + } + return problems; +} + +/** One problem for each spec after the first that repeats a value the list allows once. */ +function repeatProblems( + items: t.ConfigValue[], + valueOf: (spec: t.ConfigRecord) => string | null, + field: string, + code: string, +): t.SpecProblem[] { + const first = new Map(); + const problems: t.SpecProblem[] = []; + items.forEach((spec, index) => { + if (!isConfigRecord(spec)) return; + const value = valueOf(spec); + if (value === null) return; + const owner = first.get(value); + if (owner === undefined) { + first.set(value, index); + return; + } + const ownerSpec = items[owner]; + const name = isConfigRecord(ownerSpec) ? textOf(ownerSpec.label) || textOf(ownerSpec.name) : ''; + problems.push({ index, field, code, level: 'error', params: { name, position: owner + 1 } }); + }); + return problems; +} + +function unknownEndpointProblem( + spec: t.ConfigRecord, + index: number, + known: ReadonlySet, +): t.SpecProblem[] { + const endpoint = textOf(presetOf(spec).endpoint); + if (!endpoint || known.size === 0 || known.has(endpoint)) return []; + return [ + { + index, + field: 'preset.endpoint', + code: 'spec_unknown_endpoint', + level: 'warning', + params: { endpoint }, + }, + ]; +} + +/** + * Semantic checks of a spec list (MS-2) that the schema can't express: a + * name, label, endpoint and model (or agent) on every spec, unique names (the + * id conversations and `?spec=` links use), at most one default and one soft + * default, and a warning for an endpoint the configuration doesn't define. + */ +export function validateSpecs( + items: t.ConfigValue[], + ctx: t.SpecValidationContext, +): t.SpecProblem[] { + const perSpec = items.flatMap((spec, index) => + isConfigRecord(spec) + ? [ + ...requiredProblems(spec, index), + ...unknownEndpointProblem(spec, index, ctx.knownEndpoints), + ] + : [], + ); + return [ + ...perSpec, + ...repeatProblems(items, (spec) => textOf(spec.name) || null, 'name', 'spec_duplicate_name'), + ...repeatProblems( + items, + (spec) => (spec.default === true ? 'default' : null), + 'default', + 'spec_multiple_default', + ), + ...repeatProblems( + items, + (spec) => (spec.softDefault === true ? 'soft' : null), + 'softDefault', + 'spec_multiple_soft_default', + ), + ]; +} + +/** Problems of one spec, errors first. */ +export function problemsOf(problems: t.SpecProblem[], index: number): t.SpecProblem[] { + return problems + .filter((problem) => problem.index === index) + .sort((a, b) => Number(a.level === 'warning') - Number(b.level === 'warning')); +} + +/** The first problem of a spec field. */ +export function problemAt( + problems: t.SpecProblem[], + index: number, + field: string, +): t.SpecProblem | undefined { + return problemsOf(problems, index).find((problem) => problem.field === field); +} + +/** + * Save-blocking issues of a pending spec list, for the save pipeline (WS1): + * the errors of `validateSpecs`, at the spec's position in the pending list. + * A missing or repeated name is left out (the pipeline itself reports an + * entry without its key, and a new or renamed entry taking a used name), and + * an entry is named by its name when that is unique. + */ +export function specListIssues(changeSet: t.ConfigChangeSet): t.ConfigIssue[] { + const pending = changeSet.collections.find( + (collection) => collection.collection.path === SPEC_LIST_PATH, + ); + if (!pending || !getKeyedCollection(SPEC_LIST_PATH)) return []; + const { items } = pending; + const names = new Map(); + for (const item of items) { + const name = isConfigRecord(item) ? textOf(item.name) : ''; + if (name) names.set(name, (names.get(name) ?? 0) + 1); + } + return validateSpecs(items, { knownEndpoints: new Set() }) + .filter((problem) => problem.level === 'error') + .filter((problem) => problem.field !== 'name') + .map((problem): t.ConfigIssue => { + const spec = items[problem.index]; + const name = isConfigRecord(spec) ? textOf(spec.name) : ''; + const unique = name !== '' && names.get(name) === 1; + return { + path: `${SPEC_LIST_PATH}.${problem.index}.${problem.field}`, + code: problem.code, + message: ISSUE_TEXT[problem.code] ?? problem.code, + params: problem.params, + ...(unique && { entryIdentity: name, entryPath: problem.field }), + }; + }); +} diff --git a/src/hooks/config/save.ts b/src/hooks/config/save.ts index 60b40b64..bba93112 100644 --- a/src/hooks/config/save.ts +++ b/src/hooks/config/save.ts @@ -11,6 +11,7 @@ import { mergedBackChanges, } from '@/utils'; import { validateMcpCrossField } from '@/components/configuration/sections/mcp/validation'; +import { specListIssues } from '@/components/configuration/sections/specs/validation'; import { buildConfigChanges, normalizeEditValue } from '@/components/configuration/edits'; import { isResetSent, summarizeChanges } from '@/components/configuration/summary'; import { describeSkippedChanges } from '@/components/configuration/review/group'; @@ -236,8 +237,8 @@ export function useConfigSave({ const plan = useMemo(() => planConfigChanges(changeSet.ops, snapshot), [changeSet, snapshot]); const clientIssues = useMemo( - () => [...changeSet.issues, ...plan.issues], - [changeSet.issues, plan.issues], + () => [...changeSet.issues, ...plan.issues, ...specListIssues(changeSet)], + [changeSet, plan.issues], ); const issues = useMemo(() => [...clientIssues, ...serverIssues], [clientIssues, serverIssues]); diff --git a/src/hooks/config/sections.ts b/src/hooks/config/sections.ts index b029af00..17bc7dc9 100644 --- a/src/hooks/config/sections.ts +++ b/src/hooks/config/sections.ts @@ -18,10 +18,18 @@ const PERMISSIONS_BANNER_LINK: t.InfoBannerLink = { labelKey: 'com_config_ui_permissions_link', }; -/** Display name of a list entry: its name or label, else "Entry N". */ -function entryLabel(entry: t.ConfigValue, index: number, localize: t.LocalizeFn): string { +/** A model spec is shown by its label (what the model menu shows), else its name. */ +const SPEC_LABEL_KEYS = ['label', 'name'] as const; + +/** Display name of a list entry: its name or label (label first for specs), else "Entry N". */ +function entryLabel( + entry: t.ConfigValue, + index: number, + localize: t.LocalizeFn, + keys: readonly string[] = ['name', 'label'], +): string { if (isConfigRecord(entry)) { - for (const key of ['name', 'label'] as const) { + for (const key of keys) { const value = entry[key]; if (typeof value === 'string' && value) return value; } @@ -82,7 +90,7 @@ function collectionTocItems( const list = getValueAtPath(values, 'modelSpecs.list'); return (Array.isArray(list) ? list : []).map((entry, i) => ({ id: `section-modelSpecs-list-${i}`, - label: entryLabel(entry, i, localize), + label: entryLabel(entry, i, localize, SPEC_LABEL_KEYS), dataPath: `modelSpecs.list.${i}`, path: `modelSpecs.list.${i}`, })); diff --git a/src/hooks/useActiveSection.ts b/src/hooks/useActiveSection.ts index 9d21c975..2f90e038 100644 --- a/src/hooks/useActiveSection.ts +++ b/src/hooks/useActiveSection.ts @@ -103,6 +103,15 @@ export function useActiveSection( if (top <= probeY) bestId = id; } + /** At the top (or when nothing scrolls), the first entry is the one being read. */ + if (scrollContainer.scrollTop <= 0) { + const first = [...headers].find((header) => + tocIds.has(header.getAttribute('data-section-id') ?? ''), + ); + applyActive(first?.getAttribute('data-section-id') ?? bestId); + return; + } + // Near-bottom override: when scrolled to the bottom, pick the last visible // TOC-eligible header — it may never reach the probe point. const atBottom = diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1c8dc00e..c1b66ee5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -525,6 +525,10 @@ "com_config_issue_process_mcp_field": "can only be configured in librechat.yaml for process-backed MCP servers (command, args, env, cwd, stderr, stdio)", "com_config_issue_required": "is required", "com_config_issue_secret_required_for_rename": "Re-enter the API key to rename \"{{name}}\": its stored key can't be carried over to a new name", + "com_config_issue_spec_duplicate_name": "Spec {{position}} (“{{name}}”) already uses this name", + "com_config_issue_spec_multiple_default": "Spec {{position}} (“{{name}}”) is already the default: only one spec can be", + "com_config_issue_spec_multiple_soft_default": "Spec {{position}} (“{{name}}”) is already the soft default: only one spec can be", + "com_config_issue_spec_unknown_endpoint": "“{{endpoint}}” isn't a configured endpoint: chats with this spec fail until it exists.", "com_config_issue_too_big": "must be at most {{maximum}}", "com_config_issue_too_big_exclusive": "must be less than {{maximum}}", "com_config_issue_too_big_items": "must have at most {{maximum}} items", @@ -603,6 +607,101 @@ "com_config_section_speech_desc": "TTS and STT settings", "com_config_section_model_specs": "Model specs", "com_config_section_model_specs_desc": "Preset models users pick from the model menu", + "com_config_specs_add": "Add spec", + "com_config_specs_added": "Also show these endpoints", + "com_config_specs_added_add": "Add endpoint…", + "com_config_specs_added_desc": "Endpoints listed in the model menu next to the specs. Leave empty to list every endpoint users can use.", + "com_config_specs_added_menu_off": "The endpoint menu is off (Model select in Features › Interface), so these endpoints aren't listed.", + "com_config_specs_badge_default": "Default", + "com_config_specs_badge_default_desc": "Every new chat starts with this spec", + "com_config_specs_badge_errors_one": "{{count}} problem", + "com_config_specs_badge_errors_other": "{{count}} problems", + "com_config_specs_badge_hidden": "Hidden", + "com_config_specs_badge_hidden_desc": "Not listed in the model menu; still works when chosen by name", + "com_config_specs_badge_soft": "Soft default", + "com_config_specs_badge_soft_desc": "New chats start with this spec for users who haven't picked a model yet", + "com_config_specs_badge_warnings_one": "{{count}} warning", + "com_config_specs_badge_warnings_other": "{{count}} warnings", + "com_config_specs_chip_added": "Added {{name}}", + "com_config_specs_chip_none": "None", + "com_config_specs_chip_remove": "Remove {{name}}", + "com_config_specs_chip_removed": "Removed {{name}}", + "com_config_specs_customize": "Customize list for {{name}}", + "com_config_specs_customizing": "You're editing a copy of Base's list. When you save a change, this copy replaces Base's list for {{name}}, and later changes to Base's list won't reach them until you revert it.", + "com_config_specs_default": "Default for new chats", + "com_config_specs_default_default": "Default", + "com_config_specs_default_auto": "Default (Auto)", + "com_config_specs_default_desc": "Default: every new chat starts with this spec. Soft default: only for users who haven't picked a model yet. One spec can be each, so choosing it here takes it off any other spec.", + "com_config_specs_default_none": "No", + "com_config_specs_default_provider": "Default (provider decides)", + "com_config_specs_default_soft": "Soft default", + "com_config_specs_empty": "No model specs yet. Add one to offer a preset model in the menu.", + "com_config_specs_empty_readonly": "No model specs.", + "com_config_specs_endpoint_placeholder": "Choose an endpoint…", + "com_config_specs_endpoints_builtin": "Providers", + "com_config_specs_endpoints_custom": "Custom endpoints", + "com_config_specs_enforce_menu_on": "Users still see the endpoint menu (Model select is on in Features › Interface), but chats without a spec are refused. Turn Model select off to show only specs.", + "com_config_specs_group_advanced": "Advanced", + "com_config_specs_group_basics": "Basics", + "com_config_specs_group_capabilities": "Capabilities", + "com_config_specs_group_capabilities_desc": "Tools chats with this spec can use.", + "com_config_specs_group_defaults": "New chats", + "com_config_specs_group_menu": "Menu and visibility", + "com_config_specs_group_model": "Model", + "com_config_specs_group_model_desc": "The endpoint and model chats with this spec use.", + "com_config_specs_group_params": "Parameters", + "com_config_specs_group_params_desc": "Model settings chats with this spec start with. Unset ones use the endpoint's defaults.", + "com_config_specs_group_prompt": "Prompt", + "com_config_specs_group_subagents": "Subagents", + "com_config_specs_group_subagents_desc": "Let this spec hand work off to agents.", + "com_config_specs_icon_placeholder": "Image URL or provider name", + "com_config_specs_inherited": "These specs come from Base. A profile can't change single specs: it keeps its own copy of the whole list, which replaces Base's list for {{name}}.", + "com_config_specs_list": "Specs", + "com_config_specs_list_desc": "List order is the order of the model menu. Without a default, new chats start with the first spec when specs come first.", + "com_config_specs_mcp_add": "Add MCP server…", + "com_config_specs_mcp_servers": "MCP servers", + "com_config_specs_mode": "How users pick models", + "com_config_specs_mode_desc": "Whether chats must use a spec, start with one, or keep the user's last endpoint.", + "com_config_specs_mode_endpoints": "Endpoints first", + "com_config_specs_mode_endpoints_desc": "Specs are listed in the model menu, but new chats keep the user's last endpoint and model.", + "com_config_specs_mode_enforce": "Specs only", + "com_config_specs_mode_enforce_desc": "Every chat must use a spec; requests without one are refused.", + "com_config_specs_mode_mixed": "Specs are enforced but not put first, which LibreChat warns can behave unexpectedly: new chats don't start with a spec, yet chats without one are refused. Choose Specs only to fix it.", + "com_config_specs_mode_prioritize": "Specs first", + "com_config_specs_mode_prioritize_desc": "New chats start with a spec; users can still choose other endpoints.", + "com_config_specs_mode_revert": "Revert to librechat.yaml", + "com_config_specs_mode_revert_scope": "Revert to Base", + "com_config_specs_model_placeholder": "Model name", + "com_config_specs_new": "New spec", + "com_config_specs_not_configured": "{{name}} (not configured)", + "com_config_specs_open_model_select": "Go to Model select", + "com_config_specs_order_desc": "Not used for the menu order: the list order is. Kept for compatibility.", + "com_config_specs_params_no_endpoint": "Choose an endpoint to see its parameters.", + "com_config_specs_params_none": "{{endpoint}} has no model parameters a spec can preset.", + "com_config_specs_params_other": "Other preset fields.", + "com_config_specs_params_other_desc": "Set on this spec but not among {{endpoint}}'s parameters, so the endpoint may ignore them.", + "com_config_specs_pending_revert": "The list goes back to librechat.yaml when you save.", + "com_config_specs_pending_revert_scope": "The list goes back to Base's when you save.", + "com_config_specs_preview_added": "{{specs}}, plus {{endpoints}} in the endpoint menu.", + "com_config_specs_preview_all": "{{specs}}, plus every endpoint they can use.", + "com_config_specs_preview_enforce": "{{specs}} only. Every chat must use a spec.", + "com_config_specs_preview_hidden_one": "{{count}} more is hidden from the menu.", + "com_config_specs_preview_hidden_other": "{{count}} more are hidden from the menu.", + "com_config_specs_preview_label": "Users will see:", + "com_config_specs_preview_menu_off": "{{specs}} only. The endpoint menu is off.", + "com_config_specs_preview_none": "No specs, only the endpoint menu.", + "com_config_specs_preview_specs_one": "{{count}} spec", + "com_config_specs_preview_specs_other": "{{count}} specs", + "com_config_specs_provider_web_search": "Provider web search", + "com_config_specs_raw": "Advanced (raw)", + "com_config_specs_raw_add": "Add a preset field…", + "com_config_specs_raw_desc": "Any other preset field LibreChat accepts. Use with care: an endpoint ignores fields it doesn't support.", + "com_config_specs_raw_option": "{{label}} ({{key}})", + "com_config_specs_raw_yaml": "Preset as YAML", + "com_config_specs_rename_warning": "Renaming breaks saved conversations and ?spec= links that use “{{name}}”.", + "com_config_specs_summary_group": "in {{group}}", + "com_config_specs_this_endpoint": "this endpoint", + "com_config_specs_will_own": "Saving makes this list an admin override: later changes to the list in librechat.yaml won't apply until you revert it.", "com_config_section_mcp_servers": "MCP servers", "com_config_section_mcp_servers_desc": "Model Context Protocol servers that agents and chats can use", "com_config_section_rate_limits": "Rate limits", @@ -1029,6 +1128,49 @@ "com_config_desc_modelSpecs_list_preset": "Endpoint, model and parameters that conversations with this spec start from.", "com_config_desc_modelSpecs_list_preset_endpoint": "Endpoint the spec uses: a built-in provider or a custom endpoint's name.", "com_config_desc_modelSpecs_list_preset_model": "Model the spec uses.", + "com_config_desc_modelSpecs_list_preset_additional_instructions": "Instructions added to the assistant's own.", + "com_config_desc_modelSpecs_list_preset_agent_id": "The agent chats with this spec run. Its model and tools come from the agent.", + "com_config_desc_modelSpecs_list_preset_assistant_id": "The assistant chats with this spec use.", + "com_config_desc_modelSpecs_list_preset_disableStreaming": "Show replies only once they are complete.", + "com_config_desc_modelSpecs_list_preset_effort": "How much effort the model puts into its answer.", + "com_config_desc_modelSpecs_list_preset_fileTokenLimit": "Most tokens of attached files sent to the model.", + "com_config_desc_modelSpecs_list_preset_frequency_penalty": "Makes the model less likely to repeat the same words.", + "com_config_desc_modelSpecs_list_preset_greeting": "Text shown on the landing page when the spec has no description.", + "com_config_desc_modelSpecs_list_preset_imageDetail": "Resolution images are sent at: higher costs more tokens.", + "com_config_desc_modelSpecs_list_preset_instructions": "Instructions for the assistant, in place of its own.", + "com_config_desc_modelSpecs_list_preset_maxContextTokens": "Most tokens of the conversation sent to the model; older messages are left out.", + "com_config_desc_modelSpecs_list_preset_maxOutputTokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_maxTokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_max_tokens": "Most tokens the model can write in one reply.", + "com_config_desc_modelSpecs_list_preset_modelLabel": "Name the chat shows for the model, in place of its own.", + "com_config_desc_modelSpecs_list_preset_presence_penalty": "Makes the model more likely to move on to new topics.", + "com_config_desc_modelSpecs_list_preset_promptCache": "Cache the prompt with the provider to lower cost and latency.", + "com_config_desc_modelSpecs_list_preset_promptCacheTtl": "How long the provider keeps the cached prompt.", + "com_config_desc_modelSpecs_list_preset_promptPrefix": "Instructions sent to the model at the start of every chat with this spec.", + "com_config_desc_modelSpecs_list_preset_reasoning_context": "Which turns' reasoning the model keeps.", + "com_config_desc_modelSpecs_list_preset_reasoning_effort": "How much the model reasons before answering.", + "com_config_desc_modelSpecs_list_preset_reasoning_mode": "Reasoning mode, for models that offer one.", + "com_config_desc_modelSpecs_list_preset_reasoning_summary": "How much of its reasoning the model summarizes.", + "com_config_desc_modelSpecs_list_preset_region": "AWS region requests are sent to.", + "com_config_desc_modelSpecs_list_preset_resendFiles": "Send files attached earlier in the chat again with each message.", + "com_config_desc_modelSpecs_list_preset_stop": "Text that ends the reply when the model writes it. Spaces and line breaks count.", + "com_config_desc_modelSpecs_list_preset_system": "System prompt sent to the model at the start of every chat with this spec.", + "com_config_desc_modelSpecs_list_preset_temperature": "Higher values make answers more varied, lower values more focused.", + "com_config_desc_modelSpecs_list_preset_thinking": "Let the model think before answering.", + "com_config_desc_modelSpecs_list_preset_thinkingBudget": "Most tokens the model can spend thinking.", + "com_config_desc_modelSpecs_list_preset_thinkingDisplay": "How the model's thinking is shown.", + "com_config_desc_modelSpecs_list_preset_thinkingLevel": "How much the model thinks before answering.", + "com_config_desc_modelSpecs_list_preset_topK": "Samples only from this many of the most likely tokens.", + "com_config_desc_modelSpecs_list_preset_topP": "Samples only from the most likely tokens whose probabilities add up to this value.", + "com_config_desc_modelSpecs_list_preset_top_p": "Samples only from the most likely tokens whose probabilities add up to this value.", + "com_config_desc_modelSpecs_list_preset_url_context": "Let the model read pages from URLs in the message.", + "com_config_desc_modelSpecs_list_preset_useResponsesApi": "Use OpenAI's Responses API instead of Chat Completions.", + "com_config_desc_modelSpecs_list_preset_verbosity": "How long and detailed answers are.", + "com_config_desc_modelSpecs_list_preset_web_search": "Let the model search the web with the provider's own search tool (apart from the spec's Web search capability).", + "com_config_desc_modelSpecs_list_subagents_agent_ids": "Agents the spec can hand work off to.", + "com_config_desc_modelSpecs_list_subagents_allowSelf": "Let the spec start copies of itself as subagents.", + "com_config_desc_modelSpecs_list_subagents_enabled": "Let the spec start subagents.", + "com_config_desc_modelSpecs_list_subagents_shareFiles": "Share the chat's files with its subagents.", "com_config_desc_modelSpecs_list_runInBackground": "Let tools run in the background: off, every tool, or only the tools listed.", "com_config_desc_modelSpecs_list_showIconInHeader": "Show the spec's icon in the chat header.", "com_config_desc_modelSpecs_list_showIconInMenu": "Show the spec's icon in the model menu.", diff --git a/src/types/changes.ts b/src/types/changes.ts index ad6c01df..74402b7a 100644 --- a/src/types/changes.ts +++ b/src/types/changes.ts @@ -17,7 +17,8 @@ export type ConfigChange = * its key afterwards, so `key !== identity` is a rename. `baseline` is the * entry as the editor loaded it: only what changed since then is applied, on * top of the entry as it is stored at save time. `index` places a new entry - * in a replace-semantics list (appended when absent). + * in a replace-semantics list (appended when absent), and moves an existing + * one there (a reordered list sends every kept entry with its position). */ | { op: 'upsertEntry'; diff --git a/src/types/fields.ts b/src/types/fields.ts index db19ecad..8190ebb1 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -248,6 +248,10 @@ export interface ArrayObjectFieldProps { getEntryTitle?: (item: ConfigValue, index: number) => string | null; /** Collapsed-row summary for an entry. */ renderSummary?: (item: ConfigValue, index: number) => ReactNode; + /** Shown before an entry's title (e.g. a model spec's icon). */ + renderLeading?: (item: ConfigValue, index: number) => ReactNode; + /** Each card's summary on its own line under the title. */ + stackedSummary?: boolean; /** Offer move up/down on each card (order matters, e.g. model specs). */ reorderable?: boolean; /** Text shown when the list is empty. */ @@ -329,6 +333,10 @@ export interface ObjectEntryCardProps { markers?: EntryMarkers; /** Collapsed-row summary; `null` hides it. Defaults to the entry's type/url/label. */ summary?: ReactNode; + /** Shown before the title (e.g. a model spec's icon). */ + leading?: ReactNode; + /** The summary on its own line under the title (rich summaries, narrow cards). */ + stackedSummary?: boolean; disabled?: boolean; /** Expanded on mount, or as soon as it turns true unless the admin toggled the card. */ defaultExpanded?: boolean; diff --git a/src/types/index.ts b/src/types/index.ts index f66b369f..fd64883d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,7 @@ export type * from './scope'; export type * from './server'; export type * from './settings'; export type * from './shared'; +export type * from './specs'; export type * from './shell'; export type * from './theme'; export type * from './user'; diff --git a/src/types/specs.ts b/src/types/specs.ts new file mode 100644 index 00000000..b9ff44d9 --- /dev/null +++ b/src/types/specs.ts @@ -0,0 +1,230 @@ +import type { ReactNode } from 'react'; +import type { ConfigRecord, ConfigValue, ControlWidth, SchemaField, SelectOption } from './config'; +import type { FieldRendererProps } from './config-ui'; + +/** + * How users pick models (MS-3), stored as `modelSpecs.enforce` and + * `modelSpecs.prioritize`: specs only, specs first, or endpoints first. + */ +export type SpecSelectionMode = 'enforce' | 'prioritize' | 'endpoints'; + +/** Which "new chat" default a spec is: none, the soft default, or the default (one of each per list). */ +export type SpecDefaultKind = 'none' | 'soft' | 'default'; + +/** A problem with one spec, or with the list (`index` -1). Errors block Save; warnings don't. */ +export interface SpecProblem { + index: number; + /** Path inside the spec (`label`, `preset.endpoint`, `default`). */ + field: string; + /** Issue code; its message is `com_config_issue_` (`required`, `spec_duplicate_name`). */ + code: string; + level: 'error' | 'warning'; + params?: Record; +} + +/** What spec validation needs to know about the rest of the configuration. */ +export interface SpecValidationContext { + /** Endpoint names a spec may use: built-in providers and custom endpoint names. Empty: unknown. */ + knownEndpoints: ReadonlySet; +} + +/** Control a preset parameter gets, from LibreChat's own parameter settings (`paramSettings`). */ +export type PresetParamKind = 'number' | 'boolean' | 'enum' | 'text' | 'textarea' | 'list'; + +/** One model parameter a spec's preset can set for its endpoint (MS-4). */ +export interface PresetParam { + key: string; + kind: PresetParamKind; + min?: number; + max?: number; + step?: number; + /** Choices of an enum parameter. */ + options?: string[]; + /** What LibreChat uses when the preset leaves the parameter unset. */ + defaultValue?: ConfigValue; +} + +/** The parameters an endpoint (and model) offers, as LibreChat's parameter panel resolves them. */ +export interface PresetParamSet { + /** `paramSettings` key the parameters come from (`anthropic`, `bedrock-anthropic`, `custom`). */ + settingsKey: string | null; + params: PresetParam[]; +} + +/** Option lists the spec editor offers: endpoints, an endpoint's models, MCP servers, groups. */ +export interface SpecOptions { + endpoints: SelectOption[]; + /** Built-in providers and custom endpoint names the configuration defines. */ + knownEndpoints: ReadonlySet; + /** Names of custom endpoints (`endpoints.custom[].name`). */ + customEndpoints: ReadonlySet; + mcpServers: SelectOption[]; + groups: SelectOption[]; + /** Models to suggest for an endpoint (its configured list and other specs' models). */ + modelsFor: (endpoint: string) => SelectOption[]; + /** Custom endpoint settings by name (its `customParams` decide the parameter set). */ + customEndpoint: (name: string) => ConfigRecord | undefined; +} + +/** List-wide actions a spec editor needs (a default is unique across the list). */ +export interface SpecListActions { + setDefault: (index: number, kind: SpecDefaultKind) => void; +} + +/** Context the spec editor and summaries read (one per list). */ +export interface SpecListContextValue { + items: ConfigValue[]; + problems: SpecProblem[]; + options: SpecOptions; + actions: SpecListActions; + /** Names the specs had when loaded: renaming one of them breaks links and conversations. */ + savedNames: ReadonlySet; + /** "Set values only": rows and groups without a value are left out. */ + configuredOnly: boolean; +} + +export type ModelSpecsRendererProps = FieldRendererProps; + +export interface SpecsSettingsProps extends FieldRendererProps { + /** The specs as shown (pending edits included), for the preview line. */ + items: ConfigValue[]; + options: SpecOptions; +} + +export interface SpecListProps extends FieldRendererProps { + listField: SchemaField; + items: ConfigValue[]; + options: SpecOptions; + problems: SpecProblem[]; +} + +export interface SpecSummaryProps { + spec: ConfigValue; + index: number; +} + +export interface SpecIconProps { + iconURL: ConfigValue; + label: string; + size?: 'sm' | 'md'; +} + +/** One collapsible group of the spec editor (Basics, Model, Capabilities, …). */ +export interface SpecGroupProps { + title: string; + description?: string; + /** Data paths inside the group: it opens for a search hit or a field error there. */ + paths: string[]; + defaultExpanded?: boolean; + /** Settings the spec sets in this group, shown as a count while collapsed. */ + configuredCount?: number; + totalCount?: number; + children: ReactNode; +} + +export interface SpecEditorProps { + fields: SchemaField[]; + value: ConfigValue; + /** Data path of the spec (`modelSpecs.list.3`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; + editSessionId?: number; +} + +export interface PresetEditorProps { + presetField: SchemaField; + preset: ConfigRecord; + /** Data path of the preset (`modelSpecs.list.3.preset`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + /** Replaces the whole preset (the YAML escape hatch). */ + onReplace: (preset: ConfigRecord) => void; + disabled?: boolean; + editSessionId?: number; +} + +/** A row the spec editor renders with its own control (not the generic field control). */ +export interface SpecFieldRowProps { + path: string; + label: string; + description?: string; + required?: boolean; + /** The spec problem shown under the control (falls back to the save pipeline's error). */ + problem?: SpecProblem; + control: ReactNode; + width?: ControlWidth; + /** + * The field is empty on a spec added in this session: a missing value isn't + * flagged until the admin leaves the field (the card badge still counts it). + */ + quietWhenEmpty?: boolean; + /** More data paths the row stands for (the Default row is also the soft default), for the tab filter. */ + matchPaths?: string[]; +} + +/** A labelled group of choices (rendered as an ``). */ +export interface SpecOptionGroup { + label: string; + options: SelectOption[]; +} + +/** Picks a set of values from known choices as removable chips (Added endpoints, MCP servers). */ +export interface ChipPickerProps { + id: string; + values: string[]; + groups: SpecOptionGroup[]; + onChange: (values: string[]) => void; + /** Called instead of emitting `[]` when the last chip is removed (returns false when it can't be). */ + onClear?: () => boolean; + disabled?: boolean; + /** Label of the add picker ("Add endpoint…"). */ + addLabel: string; + /** Text of a chip whose value matches no choice ("Foo (not configured)"). */ + unknownLabel: (value: string) => string; + 'aria-label': string; + 'aria-describedby'?: string; +} + +/** A single choice from grouped options, keeping (and flagging) a value that matches none. */ +export interface GroupedSelectProps { + id: string; + value: string | undefined; + groups: SpecOptionGroup[]; + onChange: (value: string) => void; + placeholder: string; + unknownLabel: (value: string) => string; + required?: boolean; + disabled?: boolean; + 'aria-describedby'?: string; +} + +/** A note in the specs section: information, a warning, or the "Users will see" preview. */ +export interface SpecNoteProps { + tone: 'info' | 'warning' | 'preview'; + icon?: ReactNode; + /** Buttons after the text (Revert list, Customize list, Open Model select). */ + action?: ReactNode; + children: ReactNode; +} + +/** Props of one spec editor field with its own control (Label, Name, Icon, Group, Default, MCP). */ +export interface SpecFieldProps { + field?: SchemaField; + spec: ConfigRecord; + index: number; + /** Data path of the spec (`modelSpecs.list.3`). */ + path: string; + onChange: (key: string, value: ConfigValue) => void; + disabled?: boolean; +} + +/** A spec field stored in its preset (Endpoint, Model, Agent). */ +export interface SpecPresetFieldProps extends SpecFieldProps { + onPresetChange: (key: string, value: ConfigValue) => void; +} + +/** The icon of the spec (`iconURL`) or of its group (`groupIcon`). */ +export interface SpecIconFieldProps extends Omit { + keyName?: 'iconURL' | 'groupIcon'; +} diff --git a/src/utils/collections.test.ts b/src/utils/collections.test.ts index d70998bd..c9d9cc9a 100644 --- a/src/utils/collections.test.ts +++ b/src/utils/collections.test.ts @@ -403,4 +403,37 @@ describe('resolveCollectionOps — replace (modelSpecs.list)', () => { const reverted = resolve([specOp('gpt', 'gpt', yamlSpecs[0], own[0])], own, yamlSpecs, specs); expect(reverted).toMatchObject({ next: undefined, changed: true }); }); + + it('moves existing specs to their index, so a reorder alone is saved', () => { + const moves = [ + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 0 }, + { ...specOp('gpt', 'gpt', yamlSpecs[0], yamlSpecs[0]), index: 1 }, + ] as t.EntryChange[]; + const result = resolve(moves, undefined, yamlSpecs, specs); + expect(result.changed).toBe(true); + expect(result.next?.map((s) => (s as t.ConfigRecord).name)).toEqual(['claude', 'gpt']); + const back = resolve( + [ + { ...specOp('gpt', 'gpt', yamlSpecs[0], yamlSpecs[0]), index: 0 }, + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 1 }, + ] as t.EntryChange[], + [yamlSpecs[1], yamlSpecs[0]], + yamlSpecs, + specs, + ); + expect(back).toMatchObject({ next: undefined, changed: true }); + }); + + it('rebuilds a reorder with an added and a removed spec in one batch', () => { + const third = { name: 'mini', label: 'Mini', preset: { endpoint: 'openAI' } }; + const ops = [ + { ...specOp('new', null, { name: 'new', label: 'New' }), index: 0 }, + { ...specOp('claude', 'claude', yamlSpecs[1], yamlSpecs[1]), index: 1 }, + { ...specOp('mini', 'mini', third, third), index: 2 }, + { op: 'removeEntry', path: 'modelSpecs.list', key: 'gpt', identity: 'gpt' }, + ] as t.EntryChange[]; + const result = resolve(ops, undefined, [...yamlSpecs, third], specs); + expect(result.issues).toEqual([]); + expect(result.next?.map((s) => (s as t.ConfigRecord).name)).toEqual(['new', 'claude', 'mini']); + }); }); diff --git a/src/utils/collections.ts b/src/utils/collections.ts index ff3405f2..532bb044 100644 --- a/src/utils/collections.ts +++ b/src/utils/collections.ts @@ -493,8 +493,11 @@ function upsertInList(op: UpsertEntryOp, state: ResolveState): void { return; } const updated = withKey(desiredEntry(op, state.entries[index], state), key, op.key); - if (isConfigValueEqual(updated, state.entries[index])) return; - state.entries = state.entries.map((entry, i) => (i === index ? updated : entry)); + const changed = !isConfigValueEqual(updated, state.entries[index]); + const others = state.entries.filter((_, i) => i !== index); + const at = op.index === undefined ? index : Math.min(Math.max(op.index, 0), others.length); + if (!changed && at === index) return; + state.entries = [...others.slice(0, at), updated, ...others.slice(at)]; state.effective++; } @@ -512,7 +515,8 @@ function upsertInList(op: UpsertEntryOp, state: ResolveState): void { * `replace` (model specs): the result is the whole list, starting from the * target's own list or, without one, the list it inherits; taking over an * inherited list is reported as an `ownershipChange` warning, and a list - * equal to the inherited one is no override at all. + * equal to the inherited one is no override at all. An upsert with an `index` + * also moves its entry there (applied in order, they rebuild a reordering). * * An identity missing from the current layers is a conflict (`ENTRY_NOT_FOUND`). */ diff --git a/src/utils/values.test.ts b/src/utils/values.test.ts index 39fb7553..92b239fe 100644 --- a/src/utils/values.test.ts +++ b/src/utils/values.test.ts @@ -337,6 +337,33 @@ describe('deriveFieldValue', () => { }); }); +describe('deriveFieldState for model specs (a list replaced whole)', () => { + const base = baseConfigFixture({ + dbOverrides: { + modelSpecs: { + list: [ + { name: 'spec-b', label: 'Spec B' }, + { name: 'spec-a', label: 'Spec A (admin)' }, + ], + }, + }, + }); + const data = valuesDataFixture(baseLayersFixture(base)); + + it("reads each spec's lower value from the same spec, not from its index", () => { + expect(deriveFieldState('modelSpecs.list.0.label', data)).toMatchObject({ + value: 'Spec B', + source: 'admin', + revertTarget: { value: undefined, source: 'default' }, + }); + expect(deriveFieldState('modelSpecs.list.1.label', data)).toMatchObject({ + value: 'Spec A (admin)', + source: 'admin', + revertTarget: { value: 'Spec A', source: 'yaml' }, + }); + }); +}); + describe('deriveFieldState for custom endpoints (merged by name)', () => { const base = baseConfigFixture({ dbOverrides: { diff --git a/src/utils/values.ts b/src/utils/values.ts index 632791f9..ac0f96ef 100644 --- a/src/utils/values.ts +++ b/src/utils/values.ts @@ -1,9 +1,8 @@ import type * as t from '@/types'; import { hasPathOrAncestor, pathIndexOverlaps } from './paths'; import { getValueAtPath, isConfigRecord } from './format'; -import { getReadOnlyReason } from '@/constants'; +import { getReadOnlyReason, KEYED_COLLECTIONS } from '@/constants'; import { toSecretPreviewKey } from './secrets'; -import { ARRAY_MERGE_KEYS } from './layers'; /** * Value a field control shows: a pending edit first, then the merged tree @@ -146,10 +145,12 @@ interface PathReader { /** * Where `path` points inside a keyed list, identified by the entry's key in - * the displayed tree (indices differ between layers; names do not). + * the displayed tree (indices differ between layers; names do not). Lists + * LibreChat replaces whole (model specs) are read the same way: the layer + * beneath's value for a spec is that same spec's, not whichever sits at its index. */ function locateKeyed(path: string, displayRoot: t.ConfigValue): KeyedLocation | null { - for (const [arrayPath, keyField] of Object.entries(ARRAY_MERGE_KEYS)) { + for (const { path: arrayPath, key: keyField } of KEYED_COLLECTIONS.values()) { if (!path.startsWith(`${arrayPath}.`)) continue; const tail = path.slice(arrayPath.length + 1); const dot = tail.indexOf('.'); From 995c1b38e2efd53cc6e02c083e32ba03094ed9bd Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 22 Oct 2026 05:21:28 -0400 Subject: [PATCH 04/21] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Keep=20Pending=20Ed?= =?UTF-8?q?its=20Reachable=20When=20Showing=20Set=20Values=20Only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configured-only filter now treats a path holding an unsaved value as set: provider groups, generic rows, sections and table-of-contents entries stay shown, so a pending edit of an unset setting never disappears behind the filter. isPathSet moves to counts.ts as the single rule. --- .../configuration/ConfigTabContent.tsx | 11 ++++- .../configuration/ConfigTableOfContents.tsx | 6 ++- src/components/configuration/counts.test.ts | 27 +++++++++++ src/components/configuration/counts.ts | 47 +++++++++++++++++++ .../configuration/renderer/FieldRenderer.tsx | 7 ++- .../configuration/renderer/SingleField.tsx | 3 +- .../sections/providers/ProviderCard.tsx | 3 +- .../sections/providers/ProviderGroup.tsx | 11 ++++- .../sections/providers/ProvidersRenderer.tsx | 9 ++-- .../__tests__/ProvidersRenderer.test.tsx | 27 +++++++++++ .../sections/providers/layout.ts | 18 +------ 11 files changed, 136 insertions(+), 33 deletions(-) diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index 1a08735f..c63dc972 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -2,7 +2,12 @@ import { useMemo, useContext } from 'react'; import { Button } from '@clickhouse/click-ui'; import type { KeyboardEvent, ReactNode } from 'react'; import type * as t from '@/types'; -import { countSection, countSectionOverrides, countEntrySectionOverrides } from './counts'; +import { + countSection, + hasPendingValueIn, + countSectionOverrides, + countEntrySectionOverrides, +} from './counts'; import { SECTION_RENDERERS, SELF_CONTAINED_SECTION_RENDERERS } from './sections'; import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext'; import { RevealTargetContext } from '@/contexts/RevealTargetContext'; @@ -181,7 +186,9 @@ export function ConfigTabContent({ filtering && sectionHasChanges ? sections.filter((s) => sectionHasChanges[s.id]) : sections; if (showConfiguredOnly && !previewMode) { - visibleSections = visibleSections.filter((s) => (countsById[s.id]?.configured ?? 0) > 0); + visibleSections = visibleSections.filter( + (s) => (countsById[s.id]?.configured ?? 0) > 0 || hasPendingValueIn(s, editedValues), + ); } if (visibleSectionIds) { diff --git a/src/components/configuration/ConfigTableOfContents.tsx b/src/components/configuration/ConfigTableOfContents.tsx index c6448330..32542577 100644 --- a/src/components/configuration/ConfigTableOfContents.tsx +++ b/src/components/configuration/ConfigTableOfContents.tsx @@ -61,8 +61,12 @@ export const ConfigTableOfContents = memo(function ConfigTableOfContents({ [scrollContainer, onNavigate], ); + /** Configured-only keeps what is set, and what a pending edit sets, reachable. */ const isShown = (entry: t.TocEntry, isSection: boolean) => - !showConfiguredOnly || !configuredPaths || entryHasPath(entry, configuredPaths, isSection); + !showConfiguredOnly || + !configuredPaths || + entryHasPath(entry, configuredPaths, isSection) || + (!!dirtyPaths?.length && entryHasPath(entry, dirtyPaths, isSection, false)); const renderItem = (entry: t.TocEntry, isSection: boolean) => (