📥 fix: Resolve Imported-Conversation Default Model From Runtime modelsConfig (#12885)

* 📥 fix: Use Endpoint-Aware Default Model on Imported Conversations

Claude conversations imported from claude.ai's data export display
"gpt-4o-mini" in the chat UI until the page is refreshed, and any
attempt to send a message before refreshing fails with "The model
'gpt-4o-mini' is not available for Anthropic."

Root cause: ImportBatchBuilder.finishConversation() unconditionally
defaulted the saved conversation's `model` field to
openAISettings.model.default, regardless of `this.endpoint`. Claude
exports don't carry a model name, so every imported Claude conversation
landed with endpoint=anthropic but model=gpt-4o-mini.

Fix: pick the default based on `this.endpoint` via a small lookup
(openAI -> gpt-4o-mini, anthropic -> claude-3-5-sonnet-latest), keeping
the existing OpenAI default as the fallback for unknown endpoints.

Fixes #12844

* 🪄 refactor: Resolve Import Default Model From `modelsConfig`

Replace the hardcoded per-endpoint default lookup added in the previous
commit with a runtime resolver that consults the same models config the
chat UI uses (`getModelsConfig` in ModelController -> `loadDefaultModels`
+ `loadConfigModels`). This way an imported conversation defaults to a
model the LibreChat instance has actually configured / discovered for
the endpoint, instead of a hardcoded constant that may not exist on this
deployment.

Resolution order:
1. First non-empty model in `modelsConfig[endpoint]`.
2. Per-endpoint hardcoded fallback (anthropic/openAI settings) if the
   runtime config is empty for the endpoint or `getModelsConfig` throws.
3. `openAISettings.model.default` if even the per-endpoint fallback is
   missing (unknown endpoint).

`importBatchBuilder.finishConversation` now accepts an optional
`defaultModel` argument; each importer resolves it once at the top via
`resolveImportDefaultModel({ endpoint, requestUserId, userRole })` and
threads it through. ChatGPT message-level model selection also falls
back to the resolved default before the hardcoded gpt-4o-mini.
This commit is contained in:
Danny Avila 2026-04-30 13:43:04 +09:00 committed by GitHub
parent 3758380c61
commit 65990a33e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 418 additions and 10 deletions

View file

@ -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<string>} 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,
};

View file

@ -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,
);
});
});

View file

@ -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);

View file

@ -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,
);
}
/**

View file

@ -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),
);
});
});