fix(import): route Claude exports through the background job pipeline

A Claude .zip failed inspectExport because only the ChatGPT conversation
shape was recognised, and the legacy fallback is (correctly) restricted to
non-zip uploads, so the real export returned 400 and could not be imported.
inspectExport now detects the format from the shard's element shape and
runImport dispatches on it, so a Claude zip and a bare conversations.json
both flow through upload, inspect, summary, confirm, progress and report.

The job's endpoint, default model and importedFrom source now follow the
detected format, and importers.js delegates its Claude path to the same
converter instead of building a linear chain of its own.
This commit is contained in:
Marco Beretta 2026-07-26 14:41:44 +02:00
parent 07fb75def3
commit df1f7e5dc5
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
7 changed files with 305 additions and 134 deletions

View file

@ -7,8 +7,13 @@ const mongoose = require('mongoose');
const multer = require('multer');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { buildChatGptExportZip, cleanupChatGptExportZips } = require('~/test/chatgptExport');
const {
bareClaudeExport,
buildClaudeExportZip,
cleanupClaudeExportZips,
} = require('~/test/claudeExport');
const { createModels, createMethods } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { FileSources, EModelEndpoint } = require('librechat-data-provider');
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
jest.mock('~/server/middleware', () => ({
@ -193,22 +198,6 @@ function bareChatGptExport() {
);
}
function claudeExport() {
return Buffer.from(
JSON.stringify([
{
uuid: 'claude-1',
name: 'Claude convo',
created_at: '2024-01-01T00:00:00Z',
chat_messages: [
{ sender: 'human', text: 'Hi there', created_at: '2024-01-01T00:00:00Z' },
{ sender: 'assistant', text: 'Hello!', created_at: '2024-01-01T00:00:01Z' },
],
},
]),
);
}
function chatbotUiExport() {
return Buffer.from(
JSON.stringify({
@ -275,6 +264,7 @@ describe('conversation import job API (real router, real Mongo)', () => {
fs.rmSync(dir, { recursive: true, force: true });
}
cleanupChatGptExportZips();
cleanupClaudeExportZips();
});
beforeEach(async () => {
@ -355,15 +345,72 @@ describe('conversation import job API (real router, real Mongo)', () => {
expect(savedConvos).toBe(1);
});
it('still imports a Claude-shaped .json upload through the legacy synchronous path', async () => {
const res = await request(app)
.post('/api/convos/import')
.attach('file', claudeExport(), 'claude-export.json')
.expect(201);
it('imports a Claude export .zip through the job API', async () => {
const filepath = await buildClaudeExportZip();
expect(res.body.message).toBe('Conversation(s) imported successfully');
const savedConvos = await Conversation.countDocuments({ user: userId });
expect(savedConvos).toBe(1);
const uploaded = await request(app)
.post('/api/convos/import')
.attach('file', filepath)
.expect(202);
expect(uploaded.body.summary).toMatchObject({
source: 'claude',
conversations: 2,
assets: 0,
archived: 0,
starred: 0,
});
await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202);
const completed = await waitForTerminal(app, uploaded.body.jobId);
expect(completed.body.phase).toBe('completed');
expect(completed.body.report.imported).toBe(2);
expect(completed.body.report.errors).toEqual([]);
const convos = await Conversation.find({ user: userId }).lean();
expect(convos).toHaveLength(2);
expect(convos.every((convo) => convo.endpoint === EModelEndpoint.anthropic)).toBe(true);
expect(convos.map((convo) => convo.importedFrom.source).sort()).toEqual(['claude', 'claude']);
});
it('imports a bare Claude conversations.json upload through the same job API', async () => {
const uploaded = await request(app)
.post('/api/convos/import')
.attach('file', bareClaudeExport(), 'conversations.json')
.expect(202);
expect(uploaded.body.summary.source).toBe('claude');
expect(uploaded.body.summary.conversations).toBe(2);
await request(app).post(`/api/convos/import/jobs/${uploaded.body.jobId}/start`).expect(202);
const completed = await waitForTerminal(app, uploaded.body.jobId);
expect(completed.body.phase).toBe('completed');
expect(completed.body.report.imported).toBe(2);
expect(await Conversation.countDocuments({ user: userId })).toBe(2);
});
it('skips a Claude conversation already imported and does not skip ChatGPT ids', async () => {
const filepath = await buildClaudeExportZip();
const first = await request(app)
.post('/api/convos/import')
.attach('file', filepath)
.expect(202);
await request(app).post(`/api/convos/import/jobs/${first.body.jobId}/start`).expect(202);
await waitForTerminal(app, first.body.jobId);
const second = await request(app)
.post('/api/convos/import')
.attach('file', await buildClaudeExportZip())
.expect(202);
await request(app).post(`/api/convos/import/jobs/${second.body.jobId}/start`).expect(202);
const completed = await waitForTerminal(app, second.body.jobId);
expect(completed.body.report.imported).toBe(0);
expect(completed.body.report.skipped).toBe(2);
expect(await Conversation.countDocuments({ user: userId })).toBe(2);
});
it('still imports a ChatbotUI-shaped .json upload through the legacy synchronous path', async () => {

View file

@ -326,18 +326,37 @@ function handleUpload(req, res, next) {
/**
* Existing `importedFrom.externalId` values already saved for this user, used
* to skip conversations a prior (interrupted or re-run) import already wrote.
* Scoped to the export's own source: a ChatGPT conversation id can never
* collide with a Claude one, and treating them as one namespace would let one
* provider's ids suppress the other's.
* @param {string} userId
* @param {string} source - `importedFrom.source` of the export being imported.
* @returns {Promise<Set<string>>}
*/
async function loadExistingExternalIds(userId) {
async function loadExistingExternalIds(userId, source) {
const Conversation = mongoose.models.Conversation;
const rows = await Conversation.find(
{ user: userId, 'importedFrom.source': 'chatgpt' },
{ user: userId, 'importedFrom.source': source },
{ 'importedFrom.externalId': 1 },
).lean();
return new Set(rows.map((row) => row.importedFrom?.externalId).filter(Boolean));
}
/**
* The converter, endpoint and `importedFrom.source` a confirmed job needs,
* derived from the summary `inspectExport` already recorded on it. Passing the
* format through means `runImport` never re-reads a shard just to re-learn a
* shape the inspect step already established.
* @param {import('@librechat/api').ImportJob} job
* @returns {{ format: string, endpoint: string, source: string }}
*/
function resolveJobTarget(job) {
if (job.summary?.source === 'claude') {
return { format: 'claude', endpoint: EModelEndpoint.anthropic, source: 'claude' };
}
return { format: 'chatgpt', endpoint: EModelEndpoint.openAI, source: 'chatgpt' };
}
/**
* Runs a confirmed import job to completion in the background, updating the
* job record as it progresses. Never throws: failures are recorded on the job
@ -356,9 +375,10 @@ async function runImportJob(req, job) {
const source = getFileStrategy(appConfig, { isImage: true });
const { saveBuffer } = getStrategyFunctions(source);
const batch = createImportBatchBuilder(req.user.id, appConfig?.interfaceConfig);
const target = resolveJobTarget(job);
const defaultModel = await resolveImportDefaultModel({
endpoint: EModelEndpoint.openAI,
endpoint: target.endpoint,
requestUserId: req.user.id,
userRole: req.user.role,
});
@ -368,10 +388,11 @@ async function runImportJob(req, job) {
userId: req.user.id,
tenantId: req.user.tenantId,
source,
format: target.format,
defaultModel,
deps: { saveBuffer, createFile: db.createFile },
batch,
existingExternalIds: await loadExistingExternalIds(req.user.id),
existingExternalIds: await loadExistingExternalIds(req.user.id, target.source),
isCancelled: () => importJobs.isCancelled(req.user.id, job.jobId),
onProgress: async (progress) => {
await importJobs.patch(req.user.id, job.jobId, { progress });
@ -417,11 +438,12 @@ async function runImportJob(req, job) {
}
/**
* Imports a bare ChatGPT-legacy export synchronously through the old,
* un-jobbed importer. Reached only for uploads that are neither a zip nor
* recognizable ChatGPT content: Claude, ChatbotUI, and LibreChat exports
* are all detected by CONTENT (`getImporter`, invoked inside
* `importConversations`), not by file extension.
* Imports a bare export synchronously through the old, un-jobbed importer.
* Reached only for uploads that are neither a zip nor recognizable ChatGPT or
* Claude content: ChatbotUI and LibreChat exports are detected by CONTENT
* (`getImporter`, invoked inside `importConversations`), not by file extension.
* The `.zip` exclusion is load-bearing this path reads `req.file.path` as
* JSON, which no archive can satisfy.
* @param {object} req
* @param {object} res
* @returns {Promise<void>}
@ -445,14 +467,14 @@ async function importLegacyConversation(req, res) {
/**
* Imports a conversation export and saves it to the database.
*
* `.zip` uploads and bare `.json` ChatGPT exports share one pipeline:
* `openArchive` wraps a non-zip file in a single-entry archive, so
* `.zip` uploads and bare `.json` ChatGPT and Claude exports share one
* pipeline: `openArchive` wraps a non-zip file in a single-entry archive, so
* `inspectExport` sees the same shape either way and this request only
* inspects the upload and returns a summary awaiting confirmation the
* actual import runs after POST /import/jobs/:jobId/start. A bare `.json`
* upload that is not ChatGPT-shaped (Claude, ChatbotUI, LibreChat) falls
* back to the legacy synchronous importer, since `inspectExport`/`runImport`
* only understand the ChatGPT export layout.
* upload that is neither ChatGPT- nor Claude-shaped (ChatbotUI, LibreChat)
* falls back to the legacy synchronous importer, which is the only path that
* understands those layouts.
* @route POST /import
* @param {Express.Multer.File} req.file - The uploaded export file.
* @returns {object} 201 - legacy (non-ChatGPT) JSON import succeeded

View file

@ -1,7 +1,11 @@
const { v4: uuidv4 } = require('uuid');
const { convertConversation } = require('@librechat/api');
const { convertConversation, convertClaudeConversation } = require('@librechat/api');
const { logger, getTenantId } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider');
const {
Constants,
EModelEndpoint,
openAISettings,
anthropicSettings,
} = require('librechat-data-provider');
const { getEndpointsConfig } = require('~/server/services/Config');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { resolveImportDefaultModel } = require('./defaults');
@ -89,38 +93,19 @@ async function importChatBotUiConvo(
}
}
/**
* Extracts text and thinking content from a Claude message.
* @param {Object} msg - Claude message object with content array and optional text field.
* @returns {{textContent: string, thinkingContent: string}} Extracted text and thinking content.
*/
function extractClaudeContent(msg) {
let textContent = '';
let thinkingContent = '';
for (const part of msg.content || []) {
if (part.type === 'text' && part.text) {
textContent += part.text;
} else if (part.type === 'thinking' && part.thinking) {
thinkingContent += part.thinking;
}
}
// Use the text field as fallback if content array is empty
if (!textContent && msg.text) {
textContent = msg.text;
}
return { textContent, thinkingContent };
}
/**
* Imports Claude conversations from provided JSON data.
* Claude export format: array of conversations with chat_messages array.
* Delegates conversion of each conversation to `convertClaudeConversation`, the
* same engine `runImport` uses for zipped Claude exports, so a bare
* `conversations.json` upload and a zip archive produce identical messages
* branching, tool calls, reasoning, attachment extractions and citations
* included. A Claude export ships no binaries in either shape, so nothing is
* lost by this path not resolving assets.
*
* @param {Array} jsonData - Array of Claude conversation objects to be imported.
* @param {string} requestUserId - The ID of the user who initiated the import process.
* @param {Function} builderFactory - Factory function to create a new import batch builder instance.
* @param {string} [userRole] - The role of the importing user.
* @returns {Promise<void>} Promise that resolves when all conversations have been imported.
*/
async function importClaudeConvo(
@ -138,63 +123,24 @@ async function importClaudeConvo(
});
for (const conv of jsonData) {
const converted = convertClaudeConversation(conv, {
defaultModel: defaultModel || anthropicSettings.model.default,
});
importBatchBuilder.startConversation(EModelEndpoint.anthropic);
let lastMessageId = Constants.NO_PARENT;
let lastTimestamp = null;
for (const msg of conv.chat_messages || []) {
const isCreatedByUser = msg.sender === 'human';
const messageId = uuidv4();
const { textContent, thinkingContent } = extractClaudeContent(msg);
// Skip empty messages
if (!textContent && !thinkingContent) {
continue;
}
// Parse timestamp, fallback to conversation create_time or current time
const messageTime = msg.created_at || conv.created_at;
let createdAt = messageTime ? new Date(messageTime) : new Date();
// Ensure timestamp is after the previous message.
// Messages are sorted by createdAt and buildTree expects parents to appear before children.
// This guards against any potential ordering issues in exports.
if (lastTimestamp && createdAt <= lastTimestamp) {
createdAt = new Date(lastTimestamp.getTime() + 1);
}
lastTimestamp = createdAt;
const message = {
messageId,
parentMessageId: lastMessageId,
text: textContent,
sender: isCreatedByUser ? 'user' : 'Claude',
isCreatedByUser,
user: requestUserId,
endpoint: EModelEndpoint.anthropic,
createdAt,
};
// Add content array with thinking if present
if (thinkingContent && !isCreatedByUser) {
message.content = [
{ type: 'think', think: thinkingContent },
{ type: 'text', text: textContent },
];
}
importBatchBuilder.saveMessage(message);
lastMessageId = messageId;
for (const message of converted.messages) {
importBatchBuilder.saveMessage(toSaveMessageDetails(message, EModelEndpoint.anthropic));
}
const createdAt = conv.created_at ? new Date(conv.created_at) : new Date();
importBatchBuilder.finishConversation(
conv.name || 'Imported Claude Chat',
createdAt,
{},
defaultModel,
converted.title,
converted.createdAt,
{
isArchived: converted.isArchived,
pinned: converted.pinned,
model: converted.model,
importedFrom: { source: 'claude', externalId: converted.externalId },
},
converted.model,
);
}
@ -317,14 +263,15 @@ async function importLibreChatConvo(
/**
* Builds the payload `ImportBatchBuilder.saveMessage` expects from one
* message produced by the shared `convertConversation` engine (the same
* conversion used for zipped ChatGPT export imports), so a bare `.json`
* upload and a zip archive share one conversion implementation.
* message produced by a shared conversion engine (the same conversions used
* for zipped ChatGPT and Claude export imports), so a bare `.json` upload and
* a zip archive share one conversion implementation.
*
* @param {import('@librechat/api').ConvertedMessage} message
* @param {string} endpoint
* @returns {object}
*/
function toSaveMessageDetails(message) {
function toSaveMessageDetails(message, endpoint) {
return {
messageId: message.messageId,
parentMessageId: message.parentMessageId,
@ -333,7 +280,7 @@ function toSaveMessageDetails(message) {
isCreatedByUser: message.isCreatedByUser,
model: message.model,
createdAt: message.createdAt,
endpoint: EModelEndpoint.openAI,
endpoint,
content: message.content,
attachments: message.attachments,
files: message.files,
@ -377,7 +324,7 @@ async function importChatGptConvo(
importBatchBuilder.startConversation(EModelEndpoint.openAI);
for (const message of converted.messages) {
importBatchBuilder.saveMessage(toSaveMessageDetails(message));
importBatchBuilder.saveMessage(toSaveMessageDetails(message, EModelEndpoint.openAI));
}
importBatchBuilder.finishConversation(
converted.title,

View file

@ -1266,7 +1266,12 @@ describe('importClaudeConvo', () => {
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
'Test Conversation',
expect.any(Date),
{},
{
isArchived: false,
pinned: false,
model: expect.any(String),
importedFrom: { source: 'claude', externalId: 'conv-123' },
},
expect.any(String),
);
@ -1329,7 +1334,7 @@ describe('importClaudeConvo', () => {
expect(assistantMsg.content[1].text).toBe('The answer is 4.');
});
it('should not include model field (Claude exports do not contain model info)', async () => {
it('should save messages with the resolved anthropic model (Claude exports carry none)', async () => {
const jsonData = [
{
uuid: 'conv-123',
@ -1354,8 +1359,7 @@ describe('importClaudeConvo', () => {
await importer(jsonData, requestUserId, () => importBatchBuilder);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
// Model should not be explicitly set (will use ImportBatchBuilder default)
expect(savedMessages[0]).not.toHaveProperty('model');
expect(savedMessages[0].model).toBe(anthropicSettings.model.default);
});
it('should set the conversation endpoint and a Claude model so the chat UI loads correctly without a refresh', async () => {
@ -1654,7 +1658,9 @@ describe('importClaudeConvo', () => {
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
'Imported Claude Chat',
expect.any(Date),
{},
expect.objectContaining({
importedFrom: { source: 'claude', externalId: 'conv-123' },
}),
expect.any(String),
);
});

111
api/test/claudeExport.js Normal file
View file

@ -0,0 +1,111 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const JSZip = require('jszip');
const createdDirs = [];
/** The all-zero uuid a real Claude export uses to root a message. */
const ROOT_UUID = '00000000-0000-4000-8000-000000000000';
const CONVERSATIONS = [
{
uuid: 'claude-branched',
name: 'Branching answer',
created_at: '2025-12-08T17:00:00.000Z',
updated_at: '2025-12-08T17:05:00.000Z',
chat_messages: [
{
uuid: 'm1',
sender: 'human',
parent_message_uuid: ROOT_UUID,
created_at: '2025-12-08T17:00:01.000Z',
content: [{ type: 'text', text: 'Which town?' }],
},
{
uuid: 'm2a',
sender: 'assistant',
parent_message_uuid: 'm1',
created_at: '2025-12-08T17:00:02.000Z',
content: [{ type: 'text', text: 'Positano.' }],
},
{
uuid: 'm2b',
sender: 'assistant',
parent_message_uuid: 'm1',
created_at: '2025-12-08T17:00:03.000Z',
content: [{ type: 'text', text: 'Ravello.' }],
},
],
},
{
uuid: 'claude-tools',
name: 'Tooling run',
created_at: '2025-12-09T09:00:00.000Z',
chat_messages: [
{
uuid: 't1',
sender: 'human',
parent_message_uuid: null,
created_at: '2025-12-09T09:00:01.000Z',
content: [{ type: 'text', text: 'Run it' }],
},
{
uuid: 't2',
sender: 'assistant',
parent_message_uuid: 't1',
created_at: '2025-12-09T09:00:02.000Z',
content: [
{
type: 'tool_use',
id: 'tu-1',
name: 'bash_tool',
input: { command: 'ls -la' },
},
{
type: 'tool_result',
tool_use_id: 'tu-1',
name: 'bash_tool',
content: [{ type: 'text', text: 'src' }],
},
],
},
],
},
];
/** The conversations array on its own, as a bare `conversations.json` upload. */
function bareClaudeExport() {
return Buffer.from(JSON.stringify(CONVERSATIONS));
}
/**
* Minimal Claude export archive for route-level tests: `conversations.json`
* plus the out-of-scope entries a real export ships, and no binaries at all.
* @returns {Promise<string>} path to the written zip
*/
async function buildClaudeExportZip() {
const zip = new JSZip();
zip.file('conversations.json', JSON.stringify(CONVERSATIONS));
zip.file('users.json', JSON.stringify([{ uuid: 'u-1', email: 'fixture@example.com' }]));
zip.file('memories.json', JSON.stringify([]));
zip.file('projects/p-1.json', JSON.stringify({ uuid: 'p-1', name: 'Project' }));
zip.file('design_chats/d-1.json', JSON.stringify({ uuid: 'd-1' }));
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-import-claude-route-'));
createdDirs.push(dir);
const filepath = path.join(dir, 'claude-export.zip');
fs.writeFileSync(filepath, buffer);
return filepath;
}
function cleanupClaudeExportZips() {
while (createdDirs.length > 0) {
const dir = createdDirs.pop();
fs.rmSync(dir, { recursive: true, force: true });
}
}
module.exports = { bareClaudeExport, buildClaudeExportZip, cleanupClaudeExportZips };

View file

@ -330,6 +330,32 @@ describe('convertClaudeConversation', () => {
expect(converted.messages[0].createdAt.toISOString()).toBe('2025-12-08T17:00:00.000Z');
});
it('chains messages in array order when the export carries no parent uuids at all', () => {
const converted = convertClaudeConversation(
conversation([
{
uuid: 'm1',
sender: 'human',
created_at: '2025-12-08T17:00:05.000Z',
content: [{ type: 'text', text: 'First' }],
},
{
uuid: 'm2',
sender: 'assistant',
created_at: '2025-12-08T17:00:02.000Z',
content: [{ type: 'text', text: 'Second' }],
},
]),
OPTIONS,
);
const first = byText(converted.messages, 'First');
const second = byText(converted.messages, 'Second');
expect(first.parentMessageId).toBe(Constants.NO_PARENT);
expect(second.parentMessageId).toBe(first.messageId);
expect(second.createdAt.getTime()).toBeGreaterThan(first.createdAt.getTime());
});
it('returns no messages for a conversation with an empty message list', () => {
const converted = convertClaudeConversation({ uuid: 'conv-3', chat_messages: [] }, OPTIONS);
expect(converted.messages).toHaveLength(0);

View file

@ -116,13 +116,24 @@ export function convertClaudeConversation(
unavailable += entry.converted.unavailable;
}
let previousId = Constants.NO_PARENT;
for (const entry of order) {
const { message, converted } = entry;
const isCreatedByUser = message.sender === HUMAN_SENDER;
/** An export that carries no `parent_message_uuid` at all (the field is
* absent rather than null) has no tree to rebuild, so its messages chain in
* array order the shape the pre-branching importer always produced. An
* explicit `null` is a real root and is left alone. */
const parentMessageId =
message.parent_message_uuid === undefined
? previousId
: findParent(message.parent_message_uuid, prepared, emitted);
const built: ConvertedMessage = {
messageId: entry.messageId,
parentMessageId: findParent(message.parent_message_uuid, prepared, emitted),
parentMessageId,
text: converted.text,
sender: isCreatedByUser ? 'user' : ASSISTANT_SENDER,
isCreatedByUser,
@ -138,6 +149,7 @@ export function convertClaudeConversation(
built.attachments = converted.attachments;
}
previousId = built.messageId;
messages.push(built);
}