diff --git a/api/server/utils/import/defaults.js b/api/server/utils/import/defaults.js new file mode 100644 index 0000000000..2212c09359 --- /dev/null +++ b/api/server/utils/import/defaults.js @@ -0,0 +1,67 @@ +const { logger, getTenantId } = require('@librechat/data-schemas'); +const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider'); +const { getModelsConfig } = require('~/server/controllers/ModelController'); + +/** + * Last-resort hardcoded defaults used only when the runtime models config is + * unavailable or returns no models for the endpoint. + */ +const FALLBACK_MODEL_BY_ENDPOINT = { + [EModelEndpoint.openAI]: openAISettings.model.default, + [EModelEndpoint.anthropic]: anthropicSettings.model.default, +}; + +/** + * Picks the first available model for an endpoint from a runtime models config. + * + * @param {string} endpoint - The endpoint key (e.g. EModelEndpoint.anthropic). + * @param {TModelsConfig} [modelsConfig] - Map of endpoint -> available model list. + * @returns {string | undefined} The first model for the endpoint, or undefined. + */ +function pickFirstConfiguredModel(endpoint, modelsConfig) { + const models = modelsConfig?.[endpoint]; + if (!Array.isArray(models)) { + return undefined; + } + for (const model of models) { + if (typeof model === 'string' && model.length > 0) { + return model; + } + } + return undefined; +} + +/** + * Resolves the default model that imported conversations should be saved with + * for a given endpoint. Prefers the first model exposed by the runtime models + * config (admin-configured / provider-discovered), and only falls back to the + * hardcoded per-endpoint default if the runtime config is empty or fails. + * + * @param {object} args + * @param {string} args.endpoint - The endpoint key the import is targeting. + * @param {string} args.requestUserId - The id of the importing user. + * @param {string} [args.userRole] - The role of the importing user. + * @returns {Promise} The default model name to persist on the conversation. + */ +async function resolveImportDefaultModel({ endpoint, requestUserId, userRole }) { + try { + const modelsConfig = await getModelsConfig({ + user: { id: requestUserId, role: userRole, tenantId: getTenantId() }, + }); + const configured = pickFirstConfiguredModel(endpoint, modelsConfig); + if (configured) { + return configured; + } + } catch (error) { + logger.warn( + `[import] Failed to resolve default model from modelsConfig for ${endpoint}: ${error.message}`, + ); + } + return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default; +} + +module.exports = { + FALLBACK_MODEL_BY_ENDPOINT, + pickFirstConfiguredModel, + resolveImportDefaultModel, +}; diff --git a/api/server/utils/import/defaults.spec.js b/api/server/utils/import/defaults.spec.js new file mode 100644 index 0000000000..46f233afb5 --- /dev/null +++ b/api/server/utils/import/defaults.spec.js @@ -0,0 +1,122 @@ +const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider'); + +const mockGetModelsConfig = jest.fn(); + +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: (...args) => mockGetModelsConfig(...args), +})); + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + getTenantId: () => 'test-tenant', + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, + }; +}); + +const { + pickFirstConfiguredModel, + resolveImportDefaultModel, + FALLBACK_MODEL_BY_ENDPOINT, +} = require('./defaults'); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('pickFirstConfiguredModel', () => { + it('returns the first non-empty string for the endpoint', () => { + const modelsConfig = { + [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'], + }; + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, modelsConfig)).toBe( + 'claude-opus-4-7', + ); + }); + + it('skips empty strings', () => { + const modelsConfig = { + [EModelEndpoint.openAI]: ['', 'gpt-4o'], + }; + expect(pickFirstConfiguredModel(EModelEndpoint.openAI, modelsConfig)).toBe('gpt-4o'); + }); + + it('returns undefined when modelsConfig is missing', () => { + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, undefined)).toBeUndefined(); + }); + + it('returns undefined when the endpoint has no models', () => { + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, {})).toBeUndefined(); + expect( + pickFirstConfiguredModel(EModelEndpoint.anthropic, { [EModelEndpoint.anthropic]: [] }), + ).toBeUndefined(); + }); + + it('returns undefined when the endpoint value is not an array', () => { + expect( + pickFirstConfiguredModel(EModelEndpoint.anthropic, { + [EModelEndpoint.anthropic]: 'claude-opus-4-7', + }), + ).toBeUndefined(); + }); +}); + +describe('resolveImportDefaultModel', () => { + it('returns the first model from modelsConfig when present', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: ['claude-opus-4-7'], + }); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + userRole: 'USER', + }); + + expect(result).toBe('claude-opus-4-7'); + expect(mockGetModelsConfig).toHaveBeenCalledWith({ + user: { id: 'user-1', role: 'USER', tenantId: 'test-tenant' }, + }); + }); + + it('falls back to the per-endpoint default when modelsConfig has no models for the endpoint', async () => { + mockGetModelsConfig.mockResolvedValueOnce({}); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + }); + + expect(result).toBe(anthropicSettings.model.default); + }); + + it('falls back to the openAI default for unknown endpoints with no modelsConfig entry', async () => { + mockGetModelsConfig.mockResolvedValueOnce({}); + + const result = await resolveImportDefaultModel({ + endpoint: 'some-custom-endpoint', + requestUserId: 'user-1', + }); + + expect(result).toBe(openAISettings.model.default); + }); + + it('falls back to the per-endpoint default when getModelsConfig rejects', async () => { + mockGetModelsConfig.mockRejectedValueOnce(new Error('boom')); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + }); + + expect(result).toBe(anthropicSettings.model.default); + }); + + it('exposes hardcoded fallbacks for openAI and anthropic', () => { + expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI]).toBe(openAISettings.model.default); + expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.anthropic]).toBe( + anthropicSettings.model.default, + ); + }); +}); diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index 29fbfa85a2..be47cd3692 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models'); +const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); /** * Factory function for creating an instance of ImportBatchBuilder. @@ -70,9 +71,14 @@ class ImportBatchBuilder { * @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'. * @param {Date} [createdAt] - The creation date of the conversation. * @param {TConversation} [originalConvo] - The original conversation. + * @param {string} [defaultModel] - Resolved default model for this endpoint + * (typically derived from the runtime models config). Used only when + * originalConvo.model is unset. * @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages. */ - finishConversation(title, createdAt, originalConvo = {}) { + finishConversation(title, createdAt, originalConvo = {}, defaultModel) { + const fallbackModel = + defaultModel ?? FALLBACK_MODEL_BY_ENDPOINT[this.endpoint] ?? openAISettings.model.default; const convo = { ...originalConvo, user: this.requestUserId, @@ -82,7 +88,7 @@ class ImportBatchBuilder { updatedAt: createdAt, overrideTimestamp: true, endpoint: this.endpoint, - model: originalConvo.model ?? openAISettings.model.default, + model: originalConvo.model ?? fallbackModel, }; convo._id && delete convo._id; this.conversations.push(convo); diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index 7bcca41e04..b86be3798e 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -3,6 +3,7 @@ const { logger, getTenantId } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { getEndpointsConfig } = require('~/server/services/Config'); const { createImportBatchBuilder } = require('./importBatchBuilder'); +const { resolveImportDefaultModel } = require('./defaults'); const { cloneMessagesWithTimestamps } = require('./fork'); /** @@ -53,11 +54,17 @@ async function importChatBotUiConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { // this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code try { /** @type {ImportBatchBuilder} */ const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.openAI, + requestUserId, + userRole, + }); for (const historyItem of jsonData.history) { importBatchBuilder.startConversation(EModelEndpoint.openAI); @@ -68,7 +75,7 @@ async function importChatBotUiConvo( importBatchBuilder.addUserMessage(message.content); } } - importBatchBuilder.finishConversation(historyItem.name, new Date()); + importBatchBuilder.finishConversation(historyItem.name, new Date(), {}, defaultModel); } await importBatchBuilder.saveBatch(); logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`); @@ -115,9 +122,15 @@ async function importClaudeConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { try { const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId, + userRole, + }); for (const conv of jsonData) { importBatchBuilder.startConversation(EModelEndpoint.anthropic); @@ -172,7 +185,12 @@ async function importClaudeConvo( } const createdAt = conv.created_at ? new Date(conv.created_at) : new Date(); - importBatchBuilder.finishConversation(conv.name || 'Imported Claude Chat', createdAt); + importBatchBuilder.finishConversation( + conv.name || 'Imported Claude Chat', + createdAt, + {}, + defaultModel, + ); } await importBatchBuilder.saveBatch(); @@ -215,6 +233,12 @@ async function importLibreChatConvo( importBatchBuilder.startConversation(endpoint); + const defaultModel = await resolveImportDefaultModel({ + endpoint, + requestUserId, + userRole, + }); + let firstMessageDate = null; const messagesToImport = jsonData.messagesTree || jsonData.messages; @@ -271,7 +295,12 @@ async function importLibreChatConvo( firstMessageDate = null; } - importBatchBuilder.finishConversation(jsonData.title, firstMessageDate ?? new Date(), options); + importBatchBuilder.finishConversation( + jsonData.title, + firstMessageDate ?? new Date(), + options, + defaultModel, + ); await importBatchBuilder.saveBatch(); logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`); } catch (error) { @@ -292,11 +321,17 @@ async function importChatGptConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { try { const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.openAI, + requestUserId, + userRole, + }); for (const conv of jsonData) { - processConversation(conv, importBatchBuilder, requestUserId); + processConversation(conv, importBatchBuilder, requestUserId, defaultModel); } await importBatchBuilder.saveBatch(); } catch (error) { @@ -311,9 +346,10 @@ async function importChatGptConvo( * @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details. * @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data. * @param {string} requestUserId - The ID of the user who initiated the import process. + * @param {string} [defaultModel] - Resolved default model for the openAI endpoint. * @returns {void} */ -function processConversation(conv, importBatchBuilder, requestUserId) { +function processConversation(conv, importBatchBuilder, requestUserId, defaultModel) { importBatchBuilder.startConversation(EModelEndpoint.openAI); // Map all message IDs to new UUIDs @@ -437,7 +473,8 @@ function processConversation(conv, importBatchBuilder, requestUserId) { const isCreatedByUser = role === 'user'; let sender = isCreatedByUser ? 'user' : 'assistant'; - const model = mapping.message.metadata.model_slug || openAISettings.model.default; + const model = + mapping.message.metadata.model_slug || defaultModel || openAISettings.model.default; if (!isCreatedByUser) { /** Extracted model name from model slug */ @@ -487,7 +524,12 @@ function processConversation(conv, importBatchBuilder, requestUserId) { importBatchBuilder.saveMessage(message); } - importBatchBuilder.finishConversation(conv.title, new Date(conv.create_time * 1000)); + importBatchBuilder.finishConversation( + conv.title, + new Date(conv.create_time * 1000), + {}, + defaultModel, + ); } /** diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 6e712881fc..cbd39afb34 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1,6 +1,11 @@ const fs = require('fs'); const path = require('path'); -const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { + EModelEndpoint, + Constants, + openAISettings, + anthropicSettings, +} = require('librechat-data-provider'); const { getImporter, processAssistantMessage } = require('./importers'); const { ImportBatchBuilder } = require('./importBatchBuilder'); const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models'); @@ -9,10 +14,16 @@ const mockGetEndpointsConfig = jest.fn().mockResolvedValue({ [EModelEndpoint.openAI]: { userProvide: false }, }); +const mockGetModelsConfig = jest.fn().mockResolvedValue({}); + jest.mock('~/server/services/Config', () => ({ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), })); +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: (...args) => mockGetModelsConfig(...args), +})); + // Mock the database methods jest.mock('~/models', () => ({ bulkSaveConvos: jest.fn(), @@ -1013,6 +1024,28 @@ describe('importLibreChatConvo', () => { expect(result.conversation.title).toBe('Imported Chat'); expect(result.conversation.model).toBe(openAISettings.model.default); }); + + it('should default to the anthropic model for anthropic-endpoint conversations', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId); + builder.conversationId = 'conv-id-123'; + builder.messages = [{ text: 'Hello, world!' }]; + builder.endpoint = EModelEndpoint.anthropic; + const result = builder.finishConversation(); + expect(result.conversation.endpoint).toBe(EModelEndpoint.anthropic); + expect(result.conversation.model).toBe(anthropicSettings.model.default); + }); + + it('should default to the openAI model for openAI-endpoint conversations', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId); + builder.conversationId = 'conv-id-123'; + builder.messages = [{ text: 'Hello, world!' }]; + builder.endpoint = EModelEndpoint.openAI; + const result = builder.finishConversation(); + expect(result.conversation.endpoint).toBe(EModelEndpoint.openAI); + expect(result.conversation.model).toBe(openAISettings.model.default); + }); }); }); @@ -1063,11 +1096,15 @@ describe('importChatBotUiConvo', () => { 1, 'Hello what are you able to do?', expect.any(Date), + {}, + expect.any(String), ); expect(importBatchBuilder.finishConversation).toHaveBeenNthCalledWith( 2, 'Give me the code that inverts ...', expect.any(Date), + {}, + expect.any(String), ); expect(importBatchBuilder.saveBatch).toHaveBeenCalled(); @@ -1347,6 +1384,8 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Test Conversation', expect.any(Date), + {}, + expect.any(String), ); const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); @@ -1437,6 +1476,136 @@ describe('importClaudeConvo', () => { expect(savedMessages[0]).not.toHaveProperty('model'); }); + it('should set the conversation endpoint and a Claude model so the chat UI loads correctly without a refresh', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Claude Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', + content: [{ type: 'text', text: 'Hi there!' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + expect(importBatchBuilder.conversations).toHaveLength(1); + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + expect(convo.model).not.toBe(openAISettings.model.default); + }); + + it('should prefer the first runtime-configured anthropic model over the hardcoded default', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'], + }); + + const jsonData = [ + { + uuid: 'conv-456', + name: 'Configured Claude Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe('claude-opus-4-7'); + }); + + it('should fall back to the anthropic hardcoded default when modelsConfig has no anthropic models', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: [], + }); + + const jsonData = [ + { + uuid: 'conv-789', + name: 'Empty modelsConfig Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + }); + + it('should fall back to the anthropic hardcoded default when getModelsConfig throws', async () => { + mockGetModelsConfig.mockRejectedValueOnce(new Error('boom')); + + const jsonData = [ + { + uuid: 'conv-fail', + name: 'modelsConfig failure', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + }); + it('should correct timestamp inversions (child before parent)', async () => { const jsonData = [ { @@ -1603,6 +1772,8 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Imported Claude Chat', expect.any(Date), + {}, + expect.any(String), ); }); });