From 4cf7bed62ae079c8f4f96dab879cd1484807ce19 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 1/9] =?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 2/9] =?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 995c1b38e2efd53cc6e02c083e32ba03094ed9bd Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 22 Oct 2026 05:21:28 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Keep=20Pending=20Edit?= =?UTF-8?q?s=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) => (