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 = (