From 0d94881c2db0010c96340fafb806a710dd05a250 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 29 Mar 2026 01:10:57 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B9=20refactor:=20Tighten=20Config=20S?= =?UTF-8?q?chema=20Typing=20and=20Remove=20Deprecated=20Fields=20(#12452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Remove deprecated and unused fields from endpoint schemas - Remove summarize, summaryModel from endpointSchema and azureEndpointSchema - Remove plugins from azureEndpointSchema - Remove customOrder from endpointSchema and azureEndpointSchema - Remove baseURL from all and agents endpoint schemas - Type paramDefinitions with full SettingDefinition-based schema - Clean up summarize/summaryModel references in initialize.ts and config.spec.ts * refactor: Improve MCP transport schema typing - Add defaults to transport type discriminators (stdio, websocket, sse) - Type stderr field as IOType union instead of z.any() * refactor: Add narrowed preset schema for model specs - Create tModelSpecPresetSchema omitting system/DB/deprecated fields - Update tModelSpecSchema to use the narrowed preset schema * test: Add explicit type field to MCP test fixtures Add transport type discriminator to test objects that construct MCPOptions/ParsedServerConfig directly, required after type field changed from optional to default in schema definitions. * chore: Bump librechat-data-provider to 0.8.404 * refactor: Tighten z.record(z.any()) fields to precise value types - Type headers fields as z.record(z.string()) in endpoint, assistant, and azure schemas - Type addParams as z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])) - Type azure additionalHeaders as z.record(z.string()) - Type memory model_parameters as z.record(z.union([z.string(), z.number(), z.boolean()])) - Type firecrawl changeTrackingOptions.schema as z.record(z.string()) * refactor: Type supportedMimeTypes schema as z.array(z.string()) Replace z.array(z.any()).refine() with z.array(z.string()) since config input is always strings that get converted to RegExp via convertStringsToRegex() after parsing. Destructure supportedMimeTypes from spreads to avoid string[]/RegExp[] type mismatch. * refactor: Tighten enum, role, and numeric constraint schemas - Type engineSTT as enum ['openai', 'azureOpenAI'] - Type engineTTS as enum ['openai', 'azureOpenAI', 'elevenlabs', 'localai'] - Constrain playbackRate to 0.25–4 range - Type titleMessageRole as enum ['system', 'user', 'assistant'] - Add int().nonnegative() to MCP timeout and firecrawl timeout * chore: Bump librechat-data-provider to 0.8.405 * fix: Accept both string and RegExp in supportedMimeTypes schema The schema must accept both string[] (config input) and RegExp[] (post-merge runtime) since tests validate merged output against the schema. Use z.union([z.string(), z.instanceof(RegExp)]) to handle both. * refactor: Address review findings for schema tightening PR - Revert changeTrackingOptions.schema to z.record(z.unknown()) (JSON Schema is nested, not flat strings) - Remove dead contextStrategy code from BaseClient.js and cleanup.js - Extract paramDefinitionSchema to named exported constant - Add .int() constraint to columnSpan and columns - Apply consistent .int().nonnegative() to initTimeout, sseReadTimeout, scraperTimeout - Update stale stderr JSDoc to match actual accepted types - Add comprehensive tests for paramDefinitionSchema, tModelSpecPresetSchema, endpointSchema deprecated field stripping, and azureEndpointSchema * fix: Address second review pass findings - Revert supportedMimeTypesSchema to z.array(z.string()) and remove as string[] casts — fix tests to not validate merged RegExp[] output against the config input schema - Remove unused tModelSpecSchema import from test file - Consolidate duplicate '../src/schemas' imports - Add expiredAt coverage to tModelSpecPresetSchema test - Assert plugins is absent in azureEndpointSchema test - Add sync comments for engineSTT/engineTTS enum literals * refactor: Omit preset-management fields from tModelSpecPresetSchema Omit conversationId, presetId, title, defaultPreset, and order from the model spec preset schema — these are preset-management fields that don't belong in model spec configuration. --- api/app/clients/BaseClient.js | 1 - api/server/cleanup.js | 3 - .../api/src/endpoints/custom/initialize.ts | 2 - .../api/src/endpoints/openai/config.spec.ts | 4 - .../__tests__/ConnectionsRepository.test.ts | 4 +- .../MCPConnectionAgentLifecycle.test.ts | 12 +- packages/api/src/mcp/__tests__/mcp.spec.ts | 8 + .../ServerConfigsCacheInMemory.test.ts | 3 + packages/data-provider/package.json | 2 +- .../specs/config-schemas.spec.ts | 254 ++++++++++++++++++ .../data-provider/specs/filetypes.spec.ts | 7 - packages/data-provider/src/config.ts | 76 ++++-- packages/data-provider/src/file-config.ts | 31 +-- packages/data-provider/src/mcp.ts | 24 +- packages/data-provider/src/models.ts | 8 +- packages/data-provider/src/schemas.ts | 32 ++- 16 files changed, 384 insertions(+), 87 deletions(-) create mode 100644 packages/data-provider/specs/config-schemas.spec.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index ec5ccfb5f4..08cb1f6ada 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -32,7 +32,6 @@ class BaseClient { constructor(apiKey, options = {}) { this.apiKey = apiKey; this.sender = options.sender ?? 'AI'; - this.contextStrategy = null; this.currentDateString = new Date().toLocaleDateString('en-us', { year: 'numeric', month: 'long', diff --git a/api/server/cleanup.js b/api/server/cleanup.js index 364c02cd8a..c27814292d 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -123,9 +123,6 @@ function disposeClient(client) { if (client.maxContextTokens) { client.maxContextTokens = null; } - if (client.contextStrategy) { - client.contextStrategy = null; - } if (client.currentDateString) { client.currentDateString = null; } diff --git a/packages/api/src/endpoints/custom/initialize.ts b/packages/api/src/endpoints/custom/initialize.ts index 1250721500..ea0d2dbf5d 100644 --- a/packages/api/src/endpoints/custom/initialize.ts +++ b/packages/api/src/endpoints/custom/initialize.ts @@ -32,10 +32,8 @@ function buildCustomOptions( customParams: endpointConfig.customParams, titleConvo: endpointConfig.titleConvo, titleModel: endpointConfig.titleModel, - summaryModel: endpointConfig.summaryModel, modelDisplayLabel: endpointConfig.modelDisplayLabel, titleMethod: endpointConfig.titleMethod ?? 'completion', - contextStrategy: endpointConfig.summarize ? 'summarize' : null, directEndpoint: endpointConfig.directEndpoint, titleMessageRole: endpointConfig.titleMessageRole, streamRate: endpointConfig.streamRate, diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index cdf9d6f14c..46ad6a6295 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -1399,10 +1399,8 @@ describe('getOpenAIConfig', () => { dropParams: ['presence_penalty'], titleConvo: true, titleModel: 'gpt-3.5-turbo', - summaryModel: 'gpt-3.5-turbo', modelDisplayLabel: 'Custom GPT-4', titleMethod: 'completion', - contextStrategy: 'summarize', directEndpoint: true, titleMessageRole: 'user', streamRate: 25, @@ -1417,10 +1415,8 @@ describe('getOpenAIConfig', () => { customParams: {}, titleConvo: endpointConfig.titleConvo, titleModel: endpointConfig.titleModel, - summaryModel: endpointConfig.summaryModel, modelDisplayLabel: endpointConfig.modelDisplayLabel, titleMethod: endpointConfig.titleMethod, - contextStrategy: endpointConfig.contextStrategy, directEndpoint: endpointConfig.directEndpoint, titleMessageRole: endpointConfig.titleMessageRole, streamRate: endpointConfig.streamRate, diff --git a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts index 7a93960765..dfb57a1faf 100644 --- a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts +++ b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts @@ -46,8 +46,8 @@ describe('ConnectionsRepository', () => { beforeEach(() => { mockServerConfigs = { - server1: { url: 'http://localhost:3001' }, - server2: { command: 'test-command', args: ['--test'] }, + server1: { url: 'http://localhost:3001', type: 'sse' }, + server2: { command: 'test-command', args: ['--test'], type: 'stdio' }, server3: { url: 'ws://localhost:8080', type: 'websocket' }, }; diff --git a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts index 281bd590db..c7b6b273ba 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts @@ -377,7 +377,7 @@ describe('MCPConnection Agent lifecycle – SSE', () => { it('reuses the same Agents across multiple requests instead of creating one per request', async () => { conn = new MCPConnection({ serverName: 'test-sse', - serverConfig: { url: server.url }, + serverConfig: { url: server.url, type: 'sse' }, useSSRFProtection: false, }); @@ -402,7 +402,7 @@ describe('MCPConnection Agent lifecycle – SSE', () => { it('calls Agent.close() on every registered Agent when disconnect() is called', async () => { conn = new MCPConnection({ serverName: 'test-sse', - serverConfig: { url: server.url }, + serverConfig: { url: server.url, type: 'sse' }, useSSRFProtection: false, }); @@ -417,7 +417,7 @@ describe('MCPConnection Agent lifecycle – SSE', () => { it('closes at least two Agents for SSE transport (eventSourceInit + fetch)', async () => { conn = new MCPConnection({ serverName: 'test-sse', - serverConfig: { url: server.url }, + serverConfig: { url: server.url, type: 'sse' }, useSSRFProtection: false, }); @@ -431,7 +431,7 @@ describe('MCPConnection Agent lifecycle – SSE', () => { it('does not double-close Agents when disconnect() is called twice', async () => { conn = new MCPConnection({ serverName: 'test-sse', - serverConfig: { url: server.url }, + serverConfig: { url: server.url, type: 'sse' }, useSSRFProtection: false, }); @@ -533,7 +533,7 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { function makeConn() { return new MCPConnection({ serverName: 'test-404', - serverConfig: { url: 'http://127.0.0.1:1/sse' }, + serverConfig: { url: 'http://127.0.0.1:1/sse', type: 'sse' }, useSSRFProtection: false, }); } @@ -599,7 +599,7 @@ describe('MCPConnection SSE stream disconnect handling', () => { function makeConn() { return new MCPConnection({ serverName: 'test-sse-disconnect', - serverConfig: { url: 'http://127.0.0.1:1/sse' }, + serverConfig: { url: 'http://127.0.0.1:1/sse', type: 'sse' }, useSSRFProtection: false, }); } diff --git a/packages/api/src/mcp/__tests__/mcp.spec.ts b/packages/api/src/mcp/__tests__/mcp.spec.ts index d64f9f3afa..d5cc44569f 100644 --- a/packages/api/src/mcp/__tests__/mcp.spec.ts +++ b/packages/api/src/mcp/__tests__/mcp.spec.ts @@ -179,6 +179,7 @@ describe('Environment Variable Extraction (MCP)', () => { describe('processMCPEnv', () => { it('should create a deep clone of the input object', () => { const originalObj: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], env: { @@ -202,6 +203,7 @@ describe('Environment Variable Extraction (MCP)', () => { it('should process environment variables in env field', () => { const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], env: { @@ -252,6 +254,7 @@ describe('Environment Variable Extraction (MCP)', () => { it('should not modify objects without env or headers', () => { const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], timeout: 5000, @@ -433,6 +436,7 @@ describe('Environment Variable Extraction (MCP)', () => { ldapId: 'ldap-user-123', }); const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], env: { @@ -599,6 +603,7 @@ describe('Environment Variable Extraction (MCP)', () => { CUSTOM_VAR_2: 'custom-value-2', }; const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], env: { @@ -674,6 +679,7 @@ describe('Environment Variable Extraction (MCP)', () => { PROFILE_NAME: 'production-profile', }; const options: MCPOptions = { + type: 'stdio', command: 'npx', args: [ '-y', @@ -734,6 +740,7 @@ describe('Environment Variable Extraction (MCP)', () => { UNUSED_VAR: 'unused-value', }; const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['server.js'], env: { @@ -959,6 +966,7 @@ describe('Environment Variable Extraction (MCP)', () => { }) as unknown as IUser; const options: MCPOptions = { + type: 'stdio', command: 'node', args: ['mcp-server.js', '--user', '{{LIBRECHAT_USER_USERNAME}}'], env: { diff --git a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts index c123325c1f..b8827a3fe9 100644 --- a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts +++ b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts @@ -12,6 +12,7 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => { // Test data const mockConfig1: ParsedServerConfig = { + type: 'stdio', command: 'node', args: ['server1.js'], env: { TEST: 'value1' }, @@ -19,6 +20,7 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => { }; const mockConfig2: ParsedServerConfig = { + type: 'stdio', command: 'python', args: ['server2.py'], env: { TEST: 'value2' }, @@ -26,6 +28,7 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => { }; const mockConfig3: ParsedServerConfig = { + type: 'stdio', command: 'node', args: ['server3.js'], url: 'http://localhost:3000', diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 1e0c76f37f..0cbe9258f2 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.403", + "version": "0.8.405", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts new file mode 100644 index 0000000000..fabd35cec9 --- /dev/null +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -0,0 +1,254 @@ +import { + endpointSchema, + paramDefinitionSchema, + agentsEndpointSchema, + azureEndpointSchema, +} from '../src/config'; +import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas'; + +describe('paramDefinitionSchema', () => { + it('accepts a minimal definition with only key', () => { + const result = paramDefinitionSchema.safeParse({ key: 'temperature' }); + expect(result.success).toBe(true); + }); + + it('accepts a full definition with all fields', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'temperature', + type: 'number', + component: 'slider', + default: 0.7, + label: 'Temperature', + range: { min: 0, max: 2, step: 0.01 }, + columns: 2, + columnSpan: 1, + includeInput: true, + descriptionSide: 'right', + }); + expect(result.success).toBe(true); + }); + + it('rejects columns > 4', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + columns: 5, + }); + expect(result.success).toBe(false); + }); + + it('rejects columns < 1', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + columns: 0, + }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer columns', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + columns: 2.5, + }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer columnSpan', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + columnSpan: 1.5, + }); + expect(result.success).toBe(false); + }); + + it('rejects negative minTags', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + minTags: -1, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid descriptionSide', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + descriptionSide: 'diagonal', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type enum value', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + type: 'invalid', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid component enum value', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'test', + component: 'wheel', + }); + expect(result.success).toBe(false); + }); + + it('allows type and component to be omitted (merged from defaults at runtime)', () => { + const result = paramDefinitionSchema.safeParse({ + key: 'temperature', + range: { min: 0, max: 2, step: 0.01 }, + }); + expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty('type'); + expect(result.data).not.toHaveProperty('component'); + }); +}); + +describe('tModelSpecPresetSchema', () => { + it('strips system/DB fields from preset', () => { + const result = tModelSpecPresetSchema.safeParse({ + conversationId: 'conv-123', + presetId: 'preset-456', + title: 'My Preset', + defaultPreset: true, + order: 3, + isArchived: true, + user: 'user123', + messages: ['msg1'], + tags: ['tag1'], + file_ids: ['file1'], + expiredAt: '2026-12-31', + parentMessageId: 'parent1', + model: 'gpt-4o', + endpoint: EModelEndpoint.openAI, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('conversationId'); + expect(result.data).not.toHaveProperty('presetId'); + expect(result.data).not.toHaveProperty('title'); + expect(result.data).not.toHaveProperty('defaultPreset'); + expect(result.data).not.toHaveProperty('order'); + expect(result.data).not.toHaveProperty('isArchived'); + expect(result.data).not.toHaveProperty('user'); + expect(result.data).not.toHaveProperty('messages'); + expect(result.data).not.toHaveProperty('tags'); + expect(result.data).not.toHaveProperty('file_ids'); + expect(result.data).not.toHaveProperty('expiredAt'); + expect(result.data).not.toHaveProperty('parentMessageId'); + expect(result.data).toHaveProperty('model', 'gpt-4o'); + } + }); + + it('strips deprecated fields', () => { + const result = tModelSpecPresetSchema.safeParse({ + resendImages: true, + chatGptLabel: 'old-label', + model: 'gpt-4o', + endpoint: EModelEndpoint.openAI, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('resendImages'); + expect(result.data).not.toHaveProperty('chatGptLabel'); + } + }); + + it('strips frontend-only fields', () => { + const result = tModelSpecPresetSchema.safeParse({ + greeting: 'Hello!', + iconURL: 'https://example.com/icon.png', + spec: 'some-spec', + presetOverride: { model: 'other' }, + model: 'gpt-4o', + endpoint: EModelEndpoint.openAI, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('greeting'); + expect(result.data).not.toHaveProperty('iconURL'); + expect(result.data).not.toHaveProperty('spec'); + expect(result.data).not.toHaveProperty('presetOverride'); + } + }); + + it('preserves valid preset fields', () => { + const result = tModelSpecPresetSchema.safeParse({ + model: 'gpt-4o', + endpoint: EModelEndpoint.openAI, + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 4096, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.model).toBe('gpt-4o'); + expect(result.data.temperature).toBe(0.7); + expect(result.data.topP).toBe(0.9); + expect(result.data.maxOutputTokens).toBe(4096); + } + }); +}); + +describe('endpointSchema deprecated fields', () => { + const validEndpoint = { + name: 'CustomEndpoint', + apiKey: 'test-key', + baseURL: 'https://api.example.com', + models: { default: ['model-1'] }, + }; + + it('silently strips deprecated summarize field', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + summarize: true, + summaryModel: 'gpt-4o', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('summarize'); + expect(result.data).not.toHaveProperty('summaryModel'); + } + }); + + it('silently strips deprecated customOrder field', () => { + const result = endpointSchema.safeParse({ + ...validEndpoint, + customOrder: 5, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('customOrder'); + } + }); +}); + +describe('agentsEndpointSchema', () => { + it('does not accept baseURL', () => { + const result = agentsEndpointSchema.safeParse({ + baseURL: 'https://example.com', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('baseURL'); + } + }); +}); + +describe('azureEndpointSchema', () => { + it('silently strips plugins field', () => { + const result = azureEndpointSchema.safeParse({ + groups: [ + { + group: 'test-group', + apiKey: 'test-key', + models: { 'gpt-4': true }, + }, + ], + plugins: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty('plugins'); + } + }); +}); diff --git a/packages/data-provider/specs/filetypes.spec.ts b/packages/data-provider/specs/filetypes.spec.ts index 39711dadd9..dba6cd4795 100644 --- a/packages/data-provider/specs/filetypes.spec.ts +++ b/packages/data-provider/specs/filetypes.spec.ts @@ -8,7 +8,6 @@ import { retrievalMimeTypes, excelFileTypes, excelMimeTypes, - fileConfigSchema, mergeFileConfig, mbToBytes, } from '../src/file-config'; @@ -126,8 +125,6 @@ describe('mergeFileConfig', () => { test('merges minimal update correctly', () => { const result = mergeFileConfig(dynamicConfigs.minimalUpdate); expect(result.serverFileSizeLimit).toEqual(mbToBytes(1024)); - const parsedResult = fileConfigSchema.safeParse(result); - expect(parsedResult.success).toBeTruthy(); }); test('overrides default endpoint with full new configuration', () => { @@ -136,8 +133,6 @@ describe('mergeFileConfig', () => { expect(result.endpoints.default.supportedMimeTypes).toEqual( expect.arrayContaining([new RegExp('^video/.*$')]), ); - const parsedResult = fileConfigSchema.safeParse(result); - expect(parsedResult.success).toBeTruthy(); }); test('adds new endpoint configuration correctly', () => { @@ -147,8 +142,6 @@ describe('mergeFileConfig', () => { expect(result.endpoints.newEndpoint.supportedMimeTypes).toEqual( expect.arrayContaining([new RegExp('^application/json$')]), ); - const parsedResult = fileConfigSchema.safeParse(result); - expect(parsedResult.success).toBeTruthy(); }); test('disables an endpoint and sets numeric fields to 0 and empties supportedMimeTypes', () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index bb89c56f82..ae3f5b9560 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import type { ZodError } from 'zod'; import type { TEndpointsConfig, TModelsConfig, TConfig } from './types'; import { EModelEndpoint, eModelEndpointSchema, isAgentsEndpoint } from './schemas'; +import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; import { specsConfigSchema, TSpecsConfig } from './models'; import { fileConfigSchema } from './file-config'; import { apiBaseUrl } from './api-endpoints'; @@ -120,11 +121,11 @@ export const azureBaseSchema = z.object({ instanceName: z.string().optional(), deploymentName: z.string().optional(), assistants: z.boolean().optional(), - addParams: z.record(z.any()).optional(), + addParams: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), dropParams: z.array(z.string()).optional(), version: z.string().optional(), baseURL: z.string().optional(), - additionalHeaders: z.record(z.any()).optional(), + additionalHeaders: z.record(z.string()).optional(), }); export type TAzureBaseSchema = z.infer; @@ -257,7 +258,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge( userIdQuery: z.boolean().optional(), }) .optional(), - headers: z.record(z.any()).optional(), + headers: z.record(z.string()).optional(), }), ); @@ -279,6 +280,7 @@ export const defaultAgentCapabilities = [ ]; export const agentsEndpointSchema = baseEndpointSchema + .omit({ baseURL: true }) .merge( z.object({ /* agents specific */ @@ -305,6 +307,43 @@ export const agentsEndpointSchema = baseEndpointSchema export type TAgentsEndpoint = z.infer; +export const paramDefinitionSchema = z.object({ + key: z.string(), + description: z.string().optional(), + type: z.nativeEnum(SettingTypes).optional(), + default: z.union([z.number(), z.boolean(), z.string(), z.array(z.string())]).optional(), + showLabel: z.boolean().optional(), + showDefault: z.boolean().optional(), + options: z.array(z.string()).optional(), + range: z + .object({ + min: z.number(), + max: z.number(), + step: z.number().optional(), + }) + .optional(), + enumMappings: z.record(z.union([z.number(), z.boolean(), z.string()])).optional(), + component: z.nativeEnum(ComponentTypes).optional(), + optionType: z.nativeEnum(OptionTypes).optional(), + columnSpan: z.number().int().nonnegative().optional(), + columns: z.number().int().min(1).max(4).optional(), + label: z.string().optional(), + placeholder: z.string().optional(), + labelCode: z.boolean().optional(), + placeholderCode: z.boolean().optional(), + descriptionCode: z.boolean().optional(), + minText: z.number().optional(), + maxText: z.number().optional(), + minTags: z.number().min(0).optional(), + maxTags: z.number().min(0).optional(), + includeInput: z.boolean().optional(), + descriptionSide: z.enum(['top', 'right', 'bottom', 'left']).optional(), + searchPlaceholder: z.string().optional(), + selectPlaceholder: z.string().optional(), + searchPlaceholderCode: z.boolean().optional(), + selectPlaceholderCode: z.boolean().optional(), +}); + export const endpointSchema = baseEndpointSchema.merge( z.object({ name: z.string().refine((value) => !eModelEndpointSchema.safeParse(value).success, { @@ -319,23 +358,20 @@ export const endpointSchema = baseEndpointSchema.merge( fetch: z.boolean().optional(), userIdQuery: z.boolean().optional(), }), - summarize: z.boolean().optional(), - summaryModel: z.string().optional(), iconURL: z.string().optional(), modelDisplayLabel: z.string().optional(), - headers: z.record(z.any()).optional(), - addParams: z.record(z.any()).optional(), + headers: z.record(z.string()).optional(), + addParams: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), dropParams: z.array(z.string()).optional(), customParams: z .object({ defaultParamsEndpoint: z.string().default('custom'), - paramDefinitions: z.array(z.record(z.any())).optional(), + paramDefinitions: z.array(paramDefinitionSchema).optional(), }) .strict() .optional(), - customOrder: z.number().optional(), directEndpoint: z.boolean().optional(), - titleMessageRole: z.string().optional(), + titleMessageRole: z.enum(['system', 'user', 'assistant']).optional(), }), ); @@ -344,7 +380,6 @@ export type TEndpoint = z.infer; export const azureEndpointSchema = z .object({ groups: azureGroupConfigsSchema, - plugins: z.boolean().optional(), assistants: z.boolean().optional(), }) .and( @@ -356,9 +391,6 @@ export const azureEndpointSchema = z titleModel: true, titlePrompt: true, titlePromptTemplate: true, - summarize: true, - summaryModel: true, - customOrder: true, }) .partial(), ); @@ -501,7 +533,8 @@ const speechTab = z .optional() .or( z.object({ - engineSTT: z.string().optional(), + /** Keep in sync with STTProviders enum (defined below — cannot reference due to eval order) */ + engineSTT: z.enum(['openai', 'azureOpenAI']).optional(), languageSTT: z.string().optional(), autoTranscribeAudio: z.boolean().optional(), decibelValue: z.number().optional(), @@ -514,11 +547,12 @@ const speechTab = z .optional() .or( z.object({ - engineTTS: z.string().optional(), + /** Keep in sync with TTSProviders enum (defined below — cannot reference due to eval order) */ + engineTTS: z.enum(['openai', 'azureOpenAI', 'elevenlabs', 'localai']).optional(), voice: z.string().optional(), languageTTS: z.string().optional(), automaticPlayback: z.boolean().optional(), - playbackRate: z.number().optional(), + playbackRate: z.number().min(0.25).max(4).optional(), cacheTTS: z.boolean().optional(), }), ) @@ -864,7 +898,7 @@ export const webSearchSchema = z.object({ searchProvider: z.nativeEnum(SearchProviders).optional(), scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), - scraperTimeout: z.number().optional(), + scraperTimeout: z.number().int().nonnegative().optional(), safeSearch: z.nativeEnum(SafeSearchTypes).default(SafeSearchTypes.MODERATE), firecrawlOptions: z .object({ @@ -873,7 +907,7 @@ export const webSearchSchema = z.object({ excludeTags: z.array(z.string()).optional(), headers: z.record(z.string()).optional(), waitFor: z.number().optional(), - timeout: z.number().optional(), + timeout: z.number().int().nonnegative().optional(), maxAge: z.number().optional(), mobile: z.boolean().optional(), skipTlsVerification: z.boolean().optional(), @@ -942,7 +976,7 @@ export const memorySchema = z.object({ provider: z.string(), model: z.string(), instructions: z.string().optional(), - model_parameters: z.record(z.any()).optional(), + model_parameters: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), }), ]) .optional(), @@ -1026,7 +1060,7 @@ export const configSchema = z.object({ modelSpecs: specsConfigSchema.optional(), endpoints: z .object({ - all: baseEndpointSchema.optional(), + all: baseEndpointSchema.omit({ baseURL: true }).optional(), [EModelEndpoint.openAI]: baseEndpointSchema.optional(), [EModelEndpoint.google]: baseEndpointSchema.optional(), [EModelEndpoint.anthropic]: anthropicEndpointSchema.optional(), diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 32a1a28cc9..7ec184755d 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -442,22 +442,7 @@ export const fileConfig = { }, }; -const supportedMimeTypesSchema = z - .array(z.any()) - .optional() - .refine( - (mimeTypes) => { - if (!mimeTypes) { - return true; - } - return mimeTypes.every( - (mimeType) => mimeType instanceof RegExp || typeof mimeType === 'string', - ); - }, - { - message: 'Each mimeType must be a string or a RegExp object.', - }, - ); +const supportedMimeTypesSchema = z.array(z.string()).optional(); export const endpointFileConfigSchema = z.object({ disabled: z.boolean().optional(), @@ -690,22 +675,24 @@ export function mergeFileConfig(dynamic: z.infer | unde } if (dynamic.ocr !== undefined) { + const { supportedMimeTypes: ocrMimeTypes, ...ocrRest } = dynamic.ocr; mergedConfig.ocr = { ...mergedConfig.ocr, - ...dynamic.ocr, + ...ocrRest, }; - if (dynamic.ocr.supportedMimeTypes) { - mergedConfig.ocr.supportedMimeTypes = convertStringsToRegex(dynamic.ocr.supportedMimeTypes); + if (ocrMimeTypes) { + mergedConfig.ocr.supportedMimeTypes = convertStringsToRegex(ocrMimeTypes); } } if (dynamic.text !== undefined) { + const { supportedMimeTypes: textMimeTypes, ...textRest } = dynamic.text; mergedConfig.text = { ...mergedConfig.text, - ...dynamic.text, + ...textRest, }; - if (dynamic.text.supportedMimeTypes) { - mergedConfig.text.supportedMimeTypes = convertStringsToRegex(dynamic.text.supportedMimeTypes); + if (textMimeTypes) { + mergedConfig.text.supportedMimeTypes = convertStringsToRegex(textMimeTypes); } } diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 3ad296c4ec..b22a599b9b 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -18,10 +18,10 @@ const BaseOptionsSchema = z.object({ */ startup: z.boolean().optional(), iconPath: z.string().optional(), - timeout: z.number().optional(), + timeout: z.number().int().nonnegative().optional(), /** Timeout (ms) for the long-lived SSE GET stream body before undici aborts it. Default: 300_000 (5 min). */ - sseReadTimeout: z.number().positive().optional(), - initTimeout: z.number().optional(), + sseReadTimeout: z.number().int().positive().optional(), + initTimeout: z.number().int().nonnegative().optional(), /** Controls visibility in chat dropdown menu (MCPSelect) */ chatMenu: z.boolean().optional(), /** @@ -104,7 +104,7 @@ const BaseOptionsSchema = z.object({ }); export const StdioOptionsSchema = BaseOptionsSchema.extend({ - type: z.literal('stdio').optional(), + type: z.literal('stdio').default('stdio'), /** * The executable to run to start the server. */ @@ -134,17 +134,17 @@ export const StdioOptionsSchema = BaseOptionsSchema.extend({ return processedEnv; }), /** - * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. - * - * @type {import('node:child_process').IOType | import('node:stream').Stream | number} - * - * The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr. + * How to handle stderr of the child process. + * Accepts: 'pipe' | 'ignore' | 'inherit' | file descriptor number. + * Defaults to "inherit". */ - stderr: z.any().optional(), + stderr: z + .union([z.enum(['pipe', 'ignore', 'inherit']), z.number().int().nonnegative()]) + .optional(), }); export const WebSocketOptionsSchema = BaseOptionsSchema.extend({ - type: z.literal('websocket').optional(), + type: z.literal('websocket').default('websocket'), url: z .string() .transform((val: string) => extractEnvVariable(val)) @@ -161,7 +161,7 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({ }); export const SSEOptionsSchema = BaseOptionsSchema.extend({ - type: z.literal('sse').optional(), + type: z.literal('sse').default('sse'), headers: z.record(z.string(), z.string()).optional(), url: z .string() diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index c2dbe2cf77..82c2042d8a 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import type { TPreset } from './schemas'; +import type { TModelSpecPreset } from './schemas'; import { EModelEndpoint, - tPresetSchema, + tModelSpecPresetSchema, eModelEndpointSchema, AuthType, authTypeSchema, @@ -11,7 +11,7 @@ import { export type TModelSpec = { name: string; label: string; - preset: TPreset; + preset: TModelSpecPreset; order?: number; default?: boolean; description?: string; @@ -42,7 +42,7 @@ export type TModelSpec = { export const tModelSpecSchema = z.object({ name: z.string(), label: z.string(), - preset: tPresetSchema, + preset: tModelSpecPresetSchema, order: z.number().optional(), default: z.boolean().optional(), description: z.string().optional(), diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 19ba804556..084f74af86 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -635,11 +635,15 @@ export const tMessageSchema = z.object({ calibrationRatio: z .number() .optional() - .describe('EMA ratio of provider-reported vs local token estimates; seeds the pruner on subsequent runs'), + .describe( + 'EMA ratio of provider-reported vs local token estimates; seeds the pruner on subsequent runs', + ), encoding: z .string() .optional() - .describe('Tokenizer encoding used when this ratio was computed (e.g. "claude", "o200k_base")'), + .describe( + 'Tokenizer encoding used when this ratio was computed (e.g. "claude", "o200k_base")', + ), }) .optional(), }); @@ -919,6 +923,30 @@ export const tQueryParamsSchema = tConversationSchema }), ); +/** Narrowed preset schema for use in model specs — omits system/DB/deprecated fields */ +export const tModelSpecPresetSchema = tPresetSchema.omit({ + conversationId: true, + presetId: true, + title: true, + defaultPreset: true, + order: true, + isArchived: true, + user: true, + messages: true, + tags: true, + file_ids: true, + expiredAt: true, + parentMessageId: true, + resendImages: true, + chatGptLabel: true, + presetOverride: true, + greeting: true, + iconURL: true, + spec: true, +}); + +export type TModelSpecPreset = z.infer; + export type TPreset = z.infer; export type TSetOption = (