From 4cf7bed62ae079c8f4f96dab879cd1484807ce19 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 01/32] =?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 3fb0ac01e52fd3f8e576ed7615eed24be29f7089 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 20 Oct 2026 03:38:26 -0400 Subject: [PATCH 02/32] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20feat:=20MCP=20Ser?= =?UTF-8?q?vers=20Editor=20(Wave=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## MCP servers: purpose-built editor (WS12, MCP-1…MCP-14) --- e2e/mcp.spec.ts | 162 +++ src/components/configuration/ConfigPage.tsx | 4 + .../configuration/ConfigTabContent.tsx | 4 + src/components/configuration/configMeta.ts | 2 +- src/components/configuration/edits.test.ts | 13 +- src/components/configuration/issues.ts | 24 + src/components/configuration/meta/index.ts | 2 + src/components/configuration/meta/mcp.ts | 129 ++ src/components/configuration/review/diff.ts | 63 +- src/components/configuration/review/group.ts | 5 +- src/components/configuration/review/revert.ts | 11 + .../configuration/review/review.test.ts | 58 +- src/components/configuration/search.ts | 5 +- .../sections/McpServersRenderer.tsx | 1111 ---------------- .../__tests__/McpServersRenderer.test.tsx | 1114 ----------------- .../configuration/sections/index.ts | 2 +- .../configuration/sections/mcp/Card.tsx | 416 ++++++ .../configuration/sections/mcp/Create.tsx | 320 +++++ .../configuration/sections/mcp/Editor.tsx | 371 ++++++ .../configuration/sections/mcp/Field.tsx | 206 +++ .../configuration/sections/mcp/Group.tsx | 48 + .../configuration/sections/mcp/Icon.tsx | 28 + .../sections/mcp/Instructions.tsx | 91 ++ .../configuration/sections/mcp/Method.tsx | 154 +++ .../configuration/sections/mcp/Note.tsx | 46 + .../configuration/sections/mcp/Servers.tsx | 591 +++++++++ .../configuration/sections/mcp/Transport.tsx | 136 ++ .../sections/mcp/__tests__/Servers.test.tsx | 471 +++++++ .../sections/mcp/__tests__/logic.test.ts | 237 ++++ .../sections/mcp/__tests__/validation.test.ts | 108 ++ .../configuration/sections/mcp/auth.ts | 58 + .../configuration/sections/mcp/index.ts | 1 + .../configuration/sections/mcp/list.ts | 121 ++ .../configuration/sections/mcp/names.ts | 38 + .../configuration/sections/mcp/transport.ts | 107 ++ .../configuration/sections/mcp/validation.ts | 35 +- .../configuration/sections/mcp/values.ts | 14 + .../configuration/sections/mcpFieldMeta.ts | 46 - src/locales/en/translation.json | 147 +++ src/types/config-ui.ts | 8 + src/types/index.ts | 1 + src/types/mcp.ts | 198 +++ 42 files changed, 4388 insertions(+), 2318 deletions(-) create mode 100644 e2e/mcp.spec.ts create mode 100644 src/components/configuration/meta/mcp.ts delete mode 100644 src/components/configuration/sections/McpServersRenderer.tsx delete mode 100644 src/components/configuration/sections/__tests__/McpServersRenderer.test.tsx create mode 100644 src/components/configuration/sections/mcp/Card.tsx create mode 100644 src/components/configuration/sections/mcp/Create.tsx create mode 100644 src/components/configuration/sections/mcp/Editor.tsx create mode 100644 src/components/configuration/sections/mcp/Field.tsx create mode 100644 src/components/configuration/sections/mcp/Group.tsx create mode 100644 src/components/configuration/sections/mcp/Icon.tsx create mode 100644 src/components/configuration/sections/mcp/Instructions.tsx create mode 100644 src/components/configuration/sections/mcp/Method.tsx create mode 100644 src/components/configuration/sections/mcp/Note.tsx create mode 100644 src/components/configuration/sections/mcp/Servers.tsx create mode 100644 src/components/configuration/sections/mcp/Transport.tsx create mode 100644 src/components/configuration/sections/mcp/__tests__/Servers.test.tsx create mode 100644 src/components/configuration/sections/mcp/__tests__/logic.test.ts create mode 100644 src/components/configuration/sections/mcp/__tests__/validation.test.ts create mode 100644 src/components/configuration/sections/mcp/auth.ts create mode 100644 src/components/configuration/sections/mcp/index.ts create mode 100644 src/components/configuration/sections/mcp/list.ts create mode 100644 src/components/configuration/sections/mcp/names.ts create mode 100644 src/components/configuration/sections/mcp/transport.ts create mode 100644 src/components/configuration/sections/mcp/values.ts delete mode 100644 src/components/configuration/sections/mcpFieldMeta.ts create mode 100644 src/types/mcp.ts diff --git a/e2e/mcp.spec.ts b/e2e/mcp.spec.ts new file mode 100644 index 00000000..713237f3 --- /dev/null +++ b/e2e/mcp.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON } from './live'; + +/** + * MCP server journeys (WS12) from the MCP audit. They need a real LibreChat + * backend whose librechat.yaml defines the remote server E2E_MCP_REMOTE + * (with an Authorization header) and the stdio server E2E_MCP_STDIO: run + * with E2E_LIVE=1 and E2E_BASE_URL. + */ +const REMOTE = process.env.E2E_MCP_REMOTE ?? 'github'; +const PLAIN = process.env.E2E_MCP_PLAIN ?? 'cloudflare'; +const STDIO = process.env.E2E_MCP_STDIO ?? 'everything'; + +const unsavedBar = (page: Page) => page.getByRole('region', { name: 'Unsaved changes' }); + +async function openMcp(page: Page) { + await page.goto('/configuration?tab=mcp'); + await page.waitForLoadState('networkidle'); +} + +/** The expanded card of the server `key`. */ +async function card(page: Page, key: string): Promise { + const entry = page.locator(`[data-mcp-server="${key}"]`); + 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; +} + +async function openGroup(entry: Locator, title: string) { + const trigger = entry.locator('.settings-group-header button', { hasText: title }).first(); + if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click(); +} + +async function openMenu(page: Page, key: string) { + await page + .locator(`[data-mcp-server="${key}"]`) + .getByRole('button', { name: `Actions for ${key}` }) + .click(); +} + +async function menu(page: Page, key: string, item: string | RegExp) { + await openMenu(page, key); + await page.getByRole('menuitem', { name: item }).click(); +} + +async function review(page: Page) { + await unsavedBar(page) + .getByRole('button', { name: /^Review & save$/ }) + .click(); + return page.getByRole('dialog'); +} + +async function saveAndWait(page: Page) { + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /^Save$/ }).click(); + await expect(dialog).toHaveCount(0); +} + +async function setText(field: Locator, value: string) { + await field.fill(value); + await field.press('Tab'); +} + +test.describe('Configuration: MCP servers', () => { + test.skip(!LIVE, LIVE_SKIP_REASON); + test.describe.configure({ mode: 'serial', timeout: 120_000 }); + + test('turning Show in chat menu on and back to Default leaves nothing to save (MCP-6)', async ({ + page, + }) => { + await openMcp(page); + const entry = await card(page, PLAIN); + await openGroup(entry, 'Display'); + const choice = entry.locator(`#mcpServers-${PLAIN}-chatMenu`); + await choice.getByRole('button', { name: 'On', exact: true }).click(); + await expect(unsavedBar(page)).toBeVisible(); + await choice.getByRole('button', { name: /^Default/ }).click(); + await expect(unsavedBar(page)).toHaveCount(0); + }); + + test('a librechat.yaml header and the transport cannot be removed or changed (MCP-5)', async ({ + page, + }) => { + await openMcp(page); + const entry = await card(page, REMOTE); + await expect(entry.locator(`#mcpServers-${REMOTE}-headers`)).toBeVisible(); + await expect( + entry.locator(`#mcpServers-${REMOTE}-headers`).getByRole('button', { name: /^Delete / }), + ).toHaveCount(0); + await expect(entry.locator(`select#mcpServers-${REMOTE}-type`)).toHaveCount(0); + await openMenu(page, REMOTE); + await expect(page.getByRole('menuitem', { name: 'Delete' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + test('a stdio server is read-only (D10)', async ({ page }) => { + await openMcp(page); + const entry = await card(page, STDIO); + await expect(entry.getByRole('note').first()).toContainText('local process'); + await expect(entry.locator(`input#mcpServers-${STDIO}-command`)).toHaveCount(0); + await openGroup(entry, 'Display'); + await expect(entry.locator(`input#mcpServers-${STDIO}-title`)).toHaveCount(0); + }); + + test('create, rename with an edit, and delete a server (MCP-4, MCP-9, MCP-12)', async ({ + page, + }) => { + const name = `e2e-${Date.now()}`; + const renamed = `${name}-2`; + await openMcp(page); + + await page.getByRole('button', { name: 'Create MCP server' }).click(); + const create = page.getByRole('dialog'); + await expect(create.locator('#mcpDraft-transport-streamable-http')).toBeChecked(); + await expect(create.locator('#mcpDraft-transport-stdio')).toBeDisabled(); + await create.getByLabel('Server name').fill(name); + await expect(create).toContainText('Still needed: Server URL'); + await setText(create.locator('#mcpDraft-url'), 'https://mcp.e2e.example/mcp'); + await create.getByRole('button', { name: 'API key', exact: true }).click(); + const key = create.locator('#mcpDraft-apiKey-key'); + await expect(key).toHaveAttribute('type', 'password'); + await setText(key, 'sk-e2e-secret'); + await create.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(create).toHaveCount(0); + + let dialog = await review(page); + await expect(dialog).toContainText(`Added MCP server ${name}`); + await expect(dialog).not.toContainText('sk-e2e-secret'); + await saveAndWait(page); + + await page.reload(); + await page.waitForLoadState('networkidle'); + await menu(page, name, 'Rename'); + const input = page.getByRole('textbox', { name: `Rename ${name}` }); + await input.fill(renamed); + await input.press('Enter'); + const entry = await card(page, renamed); + await openGroup(entry, 'Display'); + await setText(entry.locator(`#mcpServers-${renamed}-title`), 'Renamed in e2e'); + dialog = await review(page); + await expect(dialog).toContainText(`(${name} → ${renamed})`); + await expect(dialog).toContainText('Renamed in e2e'); + await saveAndWait(page); + + await menu(page, renamed, 'Delete'); + const ghost = page.locator(`[data-mcp-deleted="${renamed}"]`); + await expect(ghost).toContainText('Will be deleted'); + await ghost.getByRole('button', { name: `Undo deleting ${renamed}` }).click(); + await expect(page.locator(`[data-mcp-server="${renamed}"]`)).toHaveCount(1); + await menu(page, renamed, 'Delete'); + dialog = await review(page); + await expect(dialog).toContainText(`Removed MCP server ${renamed}`); + await saveAndWait(page); + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator(`[data-mcp-server="${renamed}"]`)).toHaveCount(0); + }); +}); diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6a1f7bf6..25b046c1 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -607,6 +607,10 @@ export function ConfigPage({ filterPaths={filterPaths} valueLayers={layers.valueLayers} visibilityEdits={edits.visibilityEdits} + onHideField={permissions.canAssign ? edits.handleHideField : undefined} + onUnhideField={ + permissions.canAssign ? edits.handleUnhideField : undefined + } /> diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx index 1a08735f..d8643a9f 100644 --- a/src/components/configuration/ConfigTabContent.tsx +++ b/src/components/configuration/ConfigTabContent.tsx @@ -73,6 +73,8 @@ export function ConfigTabContent({ unknownSettings, valueLayers, visibilityEdits, + onHideField, + onUnhideField, visibleSectionIds, filterPaths, filterText, @@ -263,6 +265,8 @@ export function ConfigTabContent({ yamlBaseKeys: baseRecordKeys?.[dataKey], onValidationError, editSessionId, + onHideField, + onUnhideField, }; const content = ( <> diff --git a/src/components/configuration/configMeta.ts b/src/components/configuration/configMeta.ts index 4704c876..b513a5b9 100644 --- a/src/components/configuration/configMeta.ts +++ b/src/components/configuration/configMeta.ts @@ -173,7 +173,7 @@ export const SECTION_META: Record = { }, mcpSettings: { titleKey: 'com_config_section_mcp_settings', - descriptionKey: 'com_config_section_mcp_settings_desc', + descriptionKey: 'com_config_mcp_settings_intro', tab: 'mcp', order: 20, }, diff --git a/src/components/configuration/edits.test.ts b/src/components/configuration/edits.test.ts index da86c5d7..71031791 100644 --- a/src/components/configuration/edits.test.ts +++ b/src/components/configuration/edits.test.ts @@ -14,7 +14,7 @@ import { } from './edits'; import { getKeyedCollection, getValueAtPath, planConfigChanges } from '@/utils'; import { extractSchemaTree, getSchemaPathSet } from '@/server/config'; -import { issueDisplayPath, formatIssue } from './issues'; +import { issueDisplayPath, formatIssue, describeIssues } from './issues'; import { summarizeChanges } from './summary'; import { findSchemaField } from './controls'; @@ -377,6 +377,17 @@ describe('mergeIndexedArrayEdits', () => { }); describe('issue display and summary', () => { + it("shows a plain list item's first problem on the list row", () => { + const issues: t.ConfigIssue[] = [ + { path: 'mcpSettings.allowedAddresses.0', code: 'custom', message: 'must include a port' }, + { path: 'mcpSettings.allowedAddresses.0', code: 'custom', message: 'is not private' }, + ]; + const { fieldErrors } = describeIssues(issues, [], (key, params) => + key === 'com_config_issue_list_item' ? `Item ${params?.n}: ${params?.message}` : key, + ); + expect(fieldErrors['mcpSettings.allowedAddresses']).toBe('Item 1: must include a port'); + }); + const edit0 = captureCollectionEdit(custom, root); const collections: t.PendingCollection[] = [ { collection: custom, items: [endpoints[1], endpoints[0], endpoints[2]], edit: edit0 }, diff --git a/src/components/configuration/issues.ts b/src/components/configuration/issues.ts index a9044c2e..525414f2 100644 --- a/src/components/configuration/issues.ts +++ b/src/components/configuration/issues.ts @@ -83,6 +83,23 @@ export function formatIssue( return { inline, summary }; } +/** + * A plain list's item an issue is about (`mcpSettings.allowedAddresses.0`): + * the list row shows the item's first problem ("Item 1: must include a + * port"), since the list control has no row per item. Entries of keyed + * collections show their issues in their own cards. + */ +function listItemOf( + issue: t.ConfigIssue, + path: string, +): { listPath: string; index: number } | undefined { + if (issueCollection(issue)) return undefined; + const dot = path.lastIndexOf('.'); + const segment = path.slice(dot + 1); + if (dot < 0 || !/^\d+$/.test(segment)) return undefined; + return { listPath: path.slice(0, dot), index: Number(segment) }; +} + /** Per-path inline messages and a summary line per issue, for the errors map and the review. */ export function describeIssues( issues: t.ConfigIssue[], @@ -96,6 +113,13 @@ export function describeIssues( const { inline, summary } = formatIssue(issue, path, localize); fieldErrors[path] = fieldErrors[path] ? `${fieldErrors[path]}; ${inline}` : inline; if (!summaries.includes(summary)) summaries.push(summary); + const item = listItemOf(issue, path); + if (item && !fieldErrors[item.listPath]) { + fieldErrors[item.listPath] = localize('com_config_issue_list_item', { + n: item.index + 1, + message: issuePredicate(issue, localize), + }); + } } return { fieldErrors, summaries }; } diff --git a/src/components/configuration/meta/index.ts b/src/components/configuration/meta/index.ts index f2cae690..6fbf9bf7 100644 --- a/src/components/configuration/meta/index.ts +++ b/src/components/configuration/meta/index.ts @@ -5,6 +5,7 @@ import { SECRET_CONFIG_KEYS } from '@/constants'; import { ENDPOINT_FIELD_META } from './endpoints'; import { GENERIC_FIELD_META } from './generic'; import { SPEC_FIELD_META } from './specs'; +import { MCP_FIELD_META } from './mcp'; /** Registries merged in order; area files are appended here (WS2 owns, WS9–13 contribute). */ const META_SOURCES: t.FieldMetaRegistry[] = [ @@ -12,6 +13,7 @@ const META_SOURCES: t.FieldMetaRegistry[] = [ PROVIDER_FIELD_META, ENDPOINT_FIELD_META, SPEC_FIELD_META, + MCP_FIELD_META, ]; const REGISTRY = buildFieldMetaRegistry(META_SOURCES); diff --git a/src/components/configuration/meta/mcp.ts b/src/components/configuration/meta/mcp.ts new file mode 100644 index 00000000..68c2ceae --- /dev/null +++ b/src/components/configuration/meta/mcp.ts @@ -0,0 +1,129 @@ +import type * as t from '@/types'; + +const SERVER = 'mcpServers.{}'; + +/** Plain-language labels of server fields, where the schema key alone reads poorly. */ +const LABELS: Readonly> = { + type: 'com_config_mcp_label_type', + url: 'com_config_mcp_label_url', + startup: 'com_config_mcp_label_startup', + chatMenu: 'com_config_mcp_label_chatMenu', + iconPath: 'com_config_mcp_label_iconPath', + timeout: 'com_config_mcp_label_timeout', + initTimeout: 'com_config_mcp_label_initTimeout', + sseReadTimeout: 'com_config_mcp_label_sseReadTimeout', + requiresOAuth: 'com_config_mcp_label_requiresOAuth', + requestHeaders: 'com_config_mcp_label_requestHeaders', + proxy: 'com_config_mcp_label_proxy', + oauth_headers: 'com_config_mcp_label_oauth_headers', + customUserVars: 'com_config_mcp_label_customUserVars', + 'apiKey.key': 'com_config_mcp_label_apiKey_key', + 'apiKey.source': 'com_config_mcp_label_apiKey_source', + 'apiKey.authorization_type': 'com_config_mcp_label_apiKey_authorization_type', + 'apiKey.custom_header': 'com_config_mcp_label_apiKey_custom_header', + 'obo.scopes': 'com_config_mcp_label_obo_scopes', +}; + +const ENUM_LABELS: t.FieldMetaRegistry = { + [`${SERVER}.type`]: { + enumLabels: { + 'streamable-http': 'com_config_mcp_transport_streamable_http', + http: 'com_config_mcp_transport_http', + sse: 'com_config_mcp_transport_sse', + websocket: 'com_config_mcp_transport_websocket', + stdio: 'com_config_mcp_transport_stdio', + }, + }, + [`${SERVER}.apiKey.source`]: { + enumLabels: { + admin: 'com_config_mcp_enum_source_admin', + user: 'com_config_mcp_enum_source_user', + }, + }, + [`${SERVER}.apiKey.authorization_type`]: { + enumLabels: { + bearer: 'com_config_mcp_enum_auth_bearer', + basic: 'com_config_mcp_enum_auth_basic', + custom: 'com_config_mcp_enum_auth_custom', + }, + }, + [`${SERVER}.oauth.token_exchange_method`]: { + enumLabels: { + default_post: 'com_config_mcp_enum_exchange_post', + basic_auth_header: 'com_config_mcp_enum_exchange_basic', + }, + }, +}; + +/** What LibreChat's MCP connection uses when a server sets no timeout (`connection.ts`). */ +const RUNTIME_DEFAULTS: t.FieldMetaRegistry = { + [`${SERVER}.timeout`]: { runtimeDefault: 60_000 }, + [`${SERVER}.initTimeout`]: { runtimeDefault: 30_000 }, +}; + +const CONTROLS: t.FieldMetaRegistry = { + [`${SERVER}.description`]: { multiline: true }, + [`${SERVER}.oauth.client_secret`]: { secret: true }, + [`${SERVER}.url`]: { width: 'L' }, + [`${SERVER}.proxy`]: { width: 'L' }, + [`${SERVER}.iconPath`]: { width: 'L' }, +}; + +/** The create dialog's draft root, named in the tri-states it holds ("New server: Startup"). */ +const DRAFT: t.FieldMetaRegistry = { + mcpDraft: { labelKey: 'com_config_mcp_draft_label' }, +}; + +/** Catalog recovery numbers whose label repeated the unit the input already shows. */ +const CATALOG_NUMBERS = [ + 'discoveryTimeoutMs', + 'discoverySettleGraceMs', + 'reauthRetryMs', + 'generationReadTimeoutMs', + 'authorizationFenceTimeoutMs', + 'authorizationFenceRetryIntervalMs', +]; + +/** MCP settings (MCP-13): an operational-tuning label and help for catalog recovery, plain labels for its knobs. */ +function settingsMeta(): t.FieldMetaRegistry { + const entries: t.FieldMetaRegistry = { + 'mcpSettings.allowedDomains': { descriptionKey: 'com_config_mcp_settings_domains_desc' }, + 'mcpSettings.allowedAddresses': { descriptionKey: 'com_config_mcp_settings_addresses_desc' }, + 'mcpSettings.catalogRecovery': { + labelKey: 'com_config_mcp_label_catalogRecovery', + descriptionKey: 'com_config_desc_mcpSettings_catalogRecovery', + }, + }; + for (const key of CATALOG_NUMBERS) { + entries[`mcpSettings.catalogRecovery.${key}`] = { + labelKey: `com_config_mcp_label_${key}`, + unit: 'ms', + }; + } + return entries; +} + +function labelMeta(): t.FieldMetaRegistry { + return Object.fromEntries( + Object.entries(LABELS).map(([key, labelKey]) => [`${SERVER}.${key}`, { labelKey }]), + ); +} + +/** + * MCP server and MCP settings metadata (WS12): plain labels, enum labels, + * runtime defaults, multiline and secret inputs, and control widths. Merged + * after the generic registry, so its entries win. + */ +export const MCP_FIELD_META: t.FieldMetaRegistry = [ + labelMeta(), + ENUM_LABELS, + RUNTIME_DEFAULTS, + CONTROLS, + DRAFT, + settingsMeta(), +].reduce((merged, registry) => { + for (const [pattern, meta] of Object.entries(registry)) { + merged[pattern] = { ...merged[pattern], ...meta }; + } + return merged; +}, {}); diff --git a/src/components/configuration/review/diff.ts b/src/components/configuration/review/diff.ts index 32df1f0d..fe26ac88 100644 --- a/src/components/configuration/review/diff.ts +++ b/src/components/configuration/review/diff.ts @@ -539,31 +539,78 @@ function isWholeEntry(item: t.ChangeSummaryItem, kind: t.ChangeKind): boolean { return item.kind === kind && item.entryPath === item.path && item.collectionPath != null; } +/** Keys that say where a record entry points, so a renamed MCP server edited in the same save is still matched. */ +const RECORD_ENTRY_ANCHORS = ['url', 'command']; + +function sharesAnchor(before: t.ConfigValue, after: t.ConfigValue): boolean { + if (!isConfigRecord(before) || !isConfigRecord(after)) return false; + return RECORD_ENTRY_ANCHORS.some((key) => { + const value = before[key]; + return typeof value === 'string' && value !== '' && value === after[key]; + }); +} + +/** The fields a renamed entry changes as well, as rows under its new name. */ +function renamedFieldChanges( + renamed: t.ChangeSummaryItem, + before: t.ConfigRecord, + after: t.ConfigRecord, +): t.ChangeSummaryItem[] { + const beforeLeaves = flattenObject(before); + const afterLeaves = flattenObject(after); + const leaves = [...new Set([...Object.keys(beforeLeaves), ...Object.keys(afterLeaves)])]; + return leaves + .filter((leaf) => !isConfigValueEqual(beforeLeaves[leaf], afterLeaves[leaf])) + .map((leaf) => { + const path = `${renamed.path}.${leaf}`; + return { + path, + kind: leafKind(beforeLeaves[leaf], afterLeaves[leaf]), + before: beforeLeaves[leaf], + after: afterLeaves[leaf], + sourcePath: renamed.sourcePath, + secret: isSecretPath(path) || undefined, + collectionPath: renamed.collectionPath, + entryPath: renamed.entryPath, + entryIdentity: renamed.entryIdentity, + previousIdentity: renamed.previousIdentity, + }; + }); +} + /** - * A record entry removed and one added with the same value (an MCP server + * A record entry removed and one added in the same save (an MCP server * renamed: the old key's leaves are removed, the new key's written) read as - * one rename. + * one rename: an added entry with the same value as a removed one, else one + * that still points where the removed one did (same URL or command), whose + * other changes are listed under it. */ function pairRecordRenames(items: t.ChangeSummaryItem[]): t.ChangeSummaryItem[] { const removed = items.filter((item) => isWholeEntry(item, 'removed')); if (removed.length === 0) return items; const paired = new Set(); - const result = items.map((item): t.ChangeSummaryItem => { - if (!isWholeEntry(item, 'added') || !isConfigRecord(item.after)) return item; - const match = removed.find( + const findMatch = (item: t.ChangeSummaryItem, matches: typeof isConfigValueEqual) => + removed.find( (candidate) => !paired.has(candidate) && candidate.collectionPath === item.collectionPath && - isConfigValueEqual(candidate.before, item.after), + matches(candidate.before, item.after), ); - if (!match) return item; + const result = items.flatMap((item): t.ChangeSummaryItem[] => { + if (!isWholeEntry(item, 'added') || !isConfigRecord(item.after)) return [item]; + const match = findMatch(item, isConfigValueEqual) ?? findMatch(item, sharesAnchor); + if (!match) return [item]; paired.add(match); - return { + const renamed: t.ChangeSummaryItem = { ...item, kind: 'renamed', before: match.before, previousIdentity: match.entryIdentity, }; + if (isConfigValueEqual(match.before, item.after) || !isConfigRecord(match.before)) { + return [renamed]; + } + return [renamed, ...renamedFieldChanges(renamed, match.before, item.after)]; }); return result.filter((item) => !paired.has(item)); } diff --git a/src/components/configuration/review/group.ts b/src/components/configuration/review/group.ts index b097c472..27655441 100644 --- a/src/components/configuration/review/group.ts +++ b/src/components/configuration/review/group.ts @@ -201,7 +201,9 @@ function valueLabelsAt( const discriminated = discriminatorLabelsAt(path, ctx); if (discriminated) return discriminated; const field = findSchemaField(ctx.fields, path); - if (!field || !field.type.startsWith('enum')) return undefined; + if (!field || !(field.type.startsWith('enum') || field.type.startsWith('union(literal'))) { + return undefined; + } const options = getFieldOptions(field, ctx.localize); return options.length > 0 ? Object.fromEntries(options.map((option) => [option.value, option.label])) @@ -234,6 +236,7 @@ function addedDetails(item: t.ChangeSummaryItem, key: string, ctx: t.ReviewLabel after: value, secret: isSecretPath(path) || undefined, }, + valueLabels: valueLabelsAt(path, ctx), }; }); } diff --git a/src/components/configuration/review/revert.ts b/src/components/configuration/review/revert.ts index d42b4099..8218bf5c 100644 --- a/src/components/configuration/review/revert.ts +++ b/src/components/configuration/review/revert.ts @@ -201,6 +201,17 @@ export function planRevert( paths: [...pendingUnder(editedValues, source), ...pendingUnder(editedValues, previousPath)], }; } + /** A field a renamed record entry also changed: the new key goes back to the old key's value. */ + const isRenamedField = + item.kind !== 'renamed' && + item.previousIdentity != null && + item.entryPath != null && + !INDEX_SEGMENT_RE.test(item.entryPath.slice(item.entryPath.lastIndexOf('.') + 1)); + if (isRenamedField && item.path in editedValues) { + return item.before === undefined + ? { op: 'discard', paths: [item.path] } + : { op: 'set', path: item.path, value: item.before }; + } if (!isPart && !isOrder && !isRename) { const paths = pendingUnder(editedValues, source); return { diff --git a/src/components/configuration/review/review.test.ts b/src/components/configuration/review/review.test.ts index fa827d69..ab2fce05 100644 --- a/src/components/configuration/review/review.test.ts +++ b/src/components/configuration/review/review.test.ts @@ -312,6 +312,61 @@ describe('summarizeChanges: MCP servers and secrets', () => { ]); }); + it('reads a server renamed and edited in the same save as a rename plus its field change', () => { + const dbsrv = { type: 'streamable-http', url: 'https://db.example/mcp', title: 'DB' }; + const renamed = { ...dbsrv, title: 'Docs' }; + const items = summarizeChanges( + makeInput({ + baseline: { mcpServers: { dbsrv } }, + ownAt: (path) => (path === 'mcpServers.dbsrv' ? dbsrv : undefined), + saves: Object.entries(renamed).map(([key, value]) => ({ + fieldPath: `mcpServers.dbsrv2.${key}`, + value, + })), + resets: ['mcpServers.dbsrv'], + }), + ); + expect(items).toEqual([ + expect.objectContaining({ + kind: 'renamed', + path: 'mcpServers.dbsrv2', + previousIdentity: 'dbsrv', + }), + expect.objectContaining({ + kind: 'changed', + path: 'mcpServers.dbsrv2.title', + entryPath: 'mcpServers.dbsrv2', + before: 'DB', + after: 'Docs', + }), + ]); + const edited = { + 'mcpServers.dbsrv': undefined, + ...Object.fromEntries(Object.entries(renamed).map(([k, v]) => [`mcpServers.dbsrv2.${k}`, v])), + }; + expect(planRevert(items[1], edited, { mcpServers: { dbsrv } })).toEqual({ + op: 'set', + path: 'mcpServers.dbsrv2.title', + value: 'DB', + }); + }); + + it('keeps a removed server and an unrelated new one apart', () => { + const dbsrv = { type: 'sse', url: 'https://db.example/sse' }; + const items = summarizeChanges( + makeInput({ + baseline: { mcpServers: { dbsrv } }, + ownAt: (path) => (path === 'mcpServers.dbsrv' ? dbsrv : undefined), + saves: [ + { fieldPath: 'mcpServers.other.type', value: 'sse' }, + { fieldPath: 'mcpServers.other.url', value: 'https://other.example/sse' }, + ], + resets: ['mcpServers.dbsrv'], + }), + ); + expect(items.map((item) => item.kind).sort()).toEqual(['added', 'removed']); + }); + it('keeps field changes of an existing server under that server', () => { const [item] = summarizeChanges( makeInput({ baseline, saves: [{ fieldPath: 'mcpServers.github.timeout', value: 45000 }] }), @@ -415,7 +470,8 @@ describe('buildReviewModel', () => { const model = buildReviewModel(summarizeChanges(makeInput({ baseline, saves })), ctx(saves)); const [row] = model.tabs[0].sections[0].rows; expect(row.label).toBe('acme'); - expect(row.details?.map((d) => d.label)).toEqual(['Type', 'URL']); + expect(row.details?.map((d) => d.label)).toEqual(['Transport', 'Server URL']); + expect(row.details?.[0].valueLabels?.['streamable-http']).toBe('Streamable HTTP'); expect(model.count).toBe(1); }); diff --git a/src/components/configuration/search.ts b/src/components/configuration/search.ts index fcd37180..814fc3df 100644 --- a/src/components/configuration/search.ts +++ b/src/components/configuration/search.ts @@ -17,8 +17,7 @@ import { import { isEnvReference, getValueAtPath, isConfigRecord, maskSecretValues } from '@/utils'; import { getFieldMeta, labelForPath, describeField } from './meta'; import { formatFieldValue } from './controls'; -import { inferTransportType } from './sections/mcp/validation'; -import { isMcpFieldShown } from './sections/mcpFieldMeta'; +import { isMcpFieldShown, transportOf } from './sections/mcp/transport'; import { specSearchFields } from './sections/specs/params'; import { getEntryTitle } from './fields/entries'; @@ -282,7 +281,7 @@ export function buildConfigSearchIndex( function entryFields(section: t.SchemaField, entry: t.ConfigValue): t.SchemaField[] { const fields = section.children ?? []; if (section.key !== 'mcpServers' || !isConfigRecord(entry)) return fields; - const transport = inferTransportType(entry); + const transport = transportOf(entry); return fields.filter((field) => isMcpFieldShown(field.key, transport)); } diff --git a/src/components/configuration/sections/McpServersRenderer.tsx b/src/components/configuration/sections/McpServersRenderer.tsx deleted file mode 100644 index 301e72f8..00000000 --- a/src/components/configuration/sections/McpServersRenderer.tsx +++ /dev/null @@ -1,1111 +0,0 @@ -import { isProcessMCPServerConfig } from 'librechat-data-provider'; -import { memo, useRef, useMemo, useState, useEffect, useContext, useCallback } from 'react'; -import type { ReactNode } from 'react'; -import type * as t from '@/types'; -import { - setLeaf, - deepClone, - isPlainObject, - resolveEntryKey, - inferTransportType, - validateMcpCrossField, - REQUIRED_BY_TRANSPORT, -} from './mcp/validation'; -import { - isMcpFieldShown, - INSPECTOR_DERIVED, - YAML_LOCKED_FIELDS, - PROCESS_LOCKED_FIELDS, -} from './mcpFieldMeta'; -import { useCollapsibleSection } from '../useCollapsibleSection'; -import { DecorativeIcon, FormDialog } from '@/components/shared'; -import { RowFilterContext } from '@/contexts/RowFilterContext'; -import { cn, isRowVisible, isUnsafeConfigKey, isConfigValueEqual } from '@/utils'; -import { ObjectEntryCard } from '../fields/ObjectEntryCard'; -import { renderInlineField, toFieldId } from '../renderer'; -import { renderCollapsible } from '../renderCollapsible'; -import { useLocalize, useLowerEntryKeys, useRecordEntryOverrides } from '@/hooks'; -import { SelectField } from '../fields/SelectField'; -import { SettingsRow } from '../SettingsRow'; - -const TRANSPORT_TYPES = ['streamable-http', 'sse', 'websocket', 'stdio'] as const; - -/** - * Transport choices. stdio servers run a local process, which only - * librechat.yaml can define (D10): the choice is shown, disabled, unless the - * server already is one. - */ -function transportOptions(current: string, localize: t.LocalizeFn): t.SelectOption[] { - return TRANSPORT_TYPES.map((value) => { - const isLocked = value === 'stdio' && current !== 'stdio'; - const key = isLocked ? 'stdio_locked' : value.replace('-', '_'); - return { value, label: localize(`com_config_mcp_transport_${key}`), disabled: isLocked }; - }); -} - -const ALWAYS_REQUIRED = new Set(['type']); - -/** Stable empty record used as the fallback for `baseRecord`/`parentValue` when no data is available, so the downstream `useMemo` chain on `editsByEntry`/`record` does not re-fire on every render with a fresh `{}` literal. */ -const EMPTY_RECORD: Record = Object.freeze({}) as Record< - string, - t.ConfigValue ->; - -function withFieldOverrides(field: t.SchemaField, transportType: string): t.SchemaField { - if (ALWAYS_REQUIRED.has(field.key)) { - return { ...field, isOptional: false }; - } - const transportRequired = REQUIRED_BY_TRANSPORT[transportType]; - if (transportRequired?.has(field.key)) { - return { ...field, isOptional: false }; - } - return field; -} - -function applyLeafOverlay( - base: Record, - leafEdits: Array<[string[], t.ConfigValue]>, -): Record { - const cloned = deepClone(base); - for (const [segments, value] of leafEdits) { - setLeaf(cloned, segments, value); - } - return cloned; -} - -function enumerateLeafPaths( - obj: Record, - prefix: string[] = [], - seen: WeakSet = new WeakSet(), -): Array<{ segments: string[]; value: t.ConfigValue }> { - if (seen.has(obj)) return []; - seen.add(obj); - const out: Array<{ segments: string[]; value: t.ConfigValue }> = []; - for (const [k, v] of Object.entries(obj)) { - const next = [...prefix, k]; - if (isPlainObject(v)) { - out.push(...enumerateLeafPaths(v, next, seen)); - } else { - out.push({ segments: next, value: v }); - } - } - return out; -} - -const MCP_FIELD_GROUPS: t.FieldGroupDef[] = [ - { - labelKey: 'com_config_group_connection', - fields: ['type', 'url', 'command', 'args', 'headers', 'env', 'cwd', 'stderr', 'requiresOAuth'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_authentication', - fields: [], - defaultExpanded: false, - children: [ - { - labelKey: 'com_config_group_api_key', - fields: ['apiKey'], - defaultExpanded: true, - }, - { - labelKey: 'com_config_group_oauth', - fields: ['oauth', 'oauth_headers'], - defaultExpanded: false, - }, - ], - }, - { - labelKey: 'com_config_group_server_options', - fields: [ - 'title', - 'description', - 'startup', - 'chatMenu', - 'serverInstructions', - 'timeout', - 'sseReadTimeout', - 'initTimeout', - 'iconPath', - ], - defaultExpanded: false, - }, -]; - -function flattenGroupFields( - fields: t.SchemaField[], - parentValue: t.ConfigValue, - parentPath: string, - onChange: (path: string, value: t.ConfigValue) => void, - localize: (key: string, interpolation?: Record) => string, - transportType: string, - disabled?: boolean, - collectionRenderOverrides?: Record, - lockedKeys?: Set, - rowFilter: t.PathIndex | null = null, -): ReactNode[] { - const values = isPlainObject(parentValue) ? parentValue : {}; - - const nodes: ReactNode[] = []; - for (const field of fields) { - const fieldDisabled = disabled || (lockedKeys?.has(field.key) ?? false); - if (field.key === 'type') { - if (!isRowVisible(rowFilter, `${parentPath}.${field.key}`)) continue; - const fieldId = toFieldId(`${parentPath}.${field.key}`); - const label = localize(`com_config_field_${field.key}`); - const explicitValue = typeof values.type === 'string' ? values.type : ''; - const rawValue = explicitValue || inferTransportType(values); - const displayValue = rawValue === 'http' ? 'streamable-http' : rawValue; - nodes.push( -