feat(import): route Grok exports through the job pipeline and label them

Grok jobs resolve to the OpenAI endpoint and a grok importedFrom source, so
their external ids stay in their own namespace. getImporter recognises a bare
prod-grok-backend.json and delegates to the same converter the zip path uses,
and the confirmation summary names the provider instead of showing the slug.
This commit is contained in:
Marco Beretta 2026-07-26 16:26:35 +02:00
parent 261c8ee651
commit 7fd841af70
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
8 changed files with 417 additions and 7 deletions

View file

@ -12,6 +12,7 @@ const {
buildClaudeExportZip,
cleanupClaudeExportZips,
} = require('~/test/claudeExport');
const { bareGrokExport, buildGrokExportZip, cleanupGrokExportZips } = require('~/test/grokExport');
const { createModels, createMethods } = require('@librechat/data-schemas');
const { FileSources, EModelEndpoint } = require('librechat-data-provider');
@ -265,6 +266,7 @@ describe('conversation import job API (real router, real Mongo)', () => {
}
cleanupChatGptExportZips();
cleanupClaudeExportZips();
cleanupGrokExportZips();
});
beforeEach(async () => {
@ -413,6 +415,100 @@ describe('conversation import job API (real router, real Mongo)', () => {
expect(await Conversation.countDocuments({ user: userId })).toBe(2);
});
it('imports a Grok export .zip through the job API on the OpenAI endpoint', async () => {
const filepath = await buildGrokExportZip();
const uploaded = await request(app)
.post('/api/convos/import')
.attach('file', filepath)
.expect(202);
expect(uploaded.body.summary).toMatchObject({
source: 'grok',
conversations: 2,
assets: 0,
archived: 0,
starred: 1,
});
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.openAI)).toBe(true);
expect(convos.map((convo) => convo.importedFrom.source).sort()).toEqual(['grok', 'grok']);
expect(convos.map((convo) => convo.title).sort()).toEqual([
'Amalfi trip planning',
'Recovering a cut-off script',
]);
const messages = await mongoose.models.Message.find({ user: userId }).lean();
/** Five of six responses: the aborted, textless generation is dropped. */
expect(messages).toHaveLength(5);
const branch = messages.find((message) => message.text === 'Positano.');
expect(branch.sender).toBe('Grok 4.1 Thinking');
expect(branch.model).toBe('grok-4-1-thinking-1129');
expect(branch.isCreatedByUser).toBe(false);
const completedScript = messages.find(
(message) => message.text === 'Here is the completed script.',
);
const prompt = messages.find((message) => message.text === 'Complete this script.');
expect(completedScript.parentMessageId).toBe(prompt.messageId);
});
it('imports a bare prod-grok-backend.json upload through the same job API', async () => {
const uploaded = await request(app)
.post('/api/convos/import')
.attach('file', bareGrokExport(), 'prod-grok-backend.json')
.expect(202);
expect(uploaded.body.summary.source).toBe('grok');
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 Grok conversation already imported and keeps provider id namespaces apart', async () => {
const first = await request(app)
.post('/api/convos/import')
.attach('file', await buildGrokExportZip())
.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 buildGrokExportZip())
.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);
/** A Claude import on top adds its own conversations rather than being
* suppressed by the Grok external ids already recorded. */
const claude = await request(app)
.post('/api/convos/import')
.attach('file', await buildClaudeExportZip())
.expect(202);
await request(app).post(`/api/convos/import/jobs/${claude.body.jobId}/start`).expect(202);
const claudeDone = await waitForTerminal(app, claude.body.jobId);
expect(claudeDone.body.report.imported).toBe(2);
expect(await Conversation.countDocuments({ user: userId })).toBe(4);
});
it('still imports a ChatbotUI-shaped .json upload through the legacy synchronous path', async () => {
const res = await request(app)
.post('/api/convos/import')

View file

@ -7,6 +7,8 @@ const { sleep } = require('@librechat/agents');
const {
isEnabled,
runImport,
GROK_SOURCE,
GROK_ENDPOINT,
inspectExport,
ImportJobStore,
deleteAgentCheckpoints,
@ -354,6 +356,11 @@ function resolveJobTarget(job) {
if (job.summary?.source === 'claude') {
return { format: 'claude', endpoint: EModelEndpoint.anthropic, source: 'claude' };
}
/** xAI has no first-class endpoint, so Grok conversations land on OpenAI
* see `GROK_ENDPOINT`, which this must stay in step with. */
if (job.summary?.source === 'grok') {
return { format: 'grok', endpoint: GROK_ENDPOINT, source: GROK_SOURCE };
}
return { format: 'chatgpt', endpoint: EModelEndpoint.openAI, source: 'chatgpt' };
}
@ -439,8 +446,8 @@ async function runImportJob(req, job) {
/**
* 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
* Reached only for uploads that are neither a zip nor recognizable ChatGPT,
* Claude or Grok 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.
@ -467,14 +474,14 @@ async function importLegacyConversation(req, res) {
/**
* Imports a conversation export and saves it to the database.
*
* `.zip` uploads and bare `.json` ChatGPT and Claude exports share one
* `.zip` uploads and bare `.json` ChatGPT, Claude and Grok 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 neither ChatGPT- nor Claude-shaped (ChatbotUI, LibreChat)
* falls back to the legacy synchronous importer, which is the only path that
* understands those layouts.
* upload matching none of those shapes (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,4 +1,11 @@
const { convertConversation, convertClaudeConversation } = require('@librechat/api');
const {
isGrokExport,
GROK_SOURCE,
GROK_ENDPOINT,
convertConversation,
convertGrokConversation,
convertClaudeConversation,
} = require('@librechat/api');
const { logger, getTenantId } = require('@librechat/data-schemas');
const {
Constants,
@ -46,6 +53,12 @@ function getImporter(jsonData) {
return importLibreChatConvo;
}
// For Grok, the only export whose root is an object of conversations
if (isGrokExport(jsonData)) {
logger.info('Importing Grok conversation');
return importGrokConvo;
}
throw new Error('Unsupported import type');
}
@ -152,6 +165,65 @@ async function importClaudeConvo(
}
}
/**
* Imports Grok conversations from provided JSON data.
* Delegates conversion of each conversation to `convertGrokConversation`, the
* same engine `runImport` uses for zipped Grok exports, so a bare
* `prod-grok-backend.json` upload and a zip archive produce identical messages:
* branching, per-message models and dropped empty generations included. The
* binaries a Grok export ships belong to its `media_posts` rather than to any
* conversation, so nothing is lost by this path not resolving assets.
*
* @param {object} jsonData - Grok export object keyed by `conversations`.
* @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 importGrokConvo(
jsonData,
requestUserId,
builderFactory = createImportBatchBuilder,
userRole,
) {
try {
const importBatchBuilder = builderFactory(requestUserId);
const defaultModel = await resolveImportDefaultModel({
endpoint: GROK_ENDPOINT,
requestUserId,
userRole,
});
for (const entry of jsonData.conversations) {
const converted = convertGrokConversation(entry, {
defaultModel: defaultModel || openAISettings.model.default,
});
importBatchBuilder.startConversation(GROK_ENDPOINT);
for (const message of converted.messages) {
importBatchBuilder.saveMessage(toSaveMessageDetails(message, GROK_ENDPOINT));
}
importBatchBuilder.finishConversation(
converted.title,
converted.createdAt,
{
isArchived: converted.isArchived,
pinned: converted.pinned,
model: converted.model,
importedFrom: { source: GROK_SOURCE, externalId: converted.externalId },
},
converted.model,
);
}
await importBatchBuilder.saveBatch();
logger.info(`user: ${requestUserId} | Grok conversation imported`);
} catch (error) {
logger.error(`user: ${requestUserId} | Error creating conversation from Grok file`, error);
throw error;
}
}
/**
* Imports a LibreChat conversation from JSON.
*

View file

@ -1665,3 +1665,65 @@ describe('importClaudeConvo', () => {
);
});
});
describe('importGrokConvo', () => {
const { EXPORT: grokExport } = require('~/test/grokExport');
it('routes a Grok export object to the Grok importer', () => {
expect(getImporter(grokExport)).toBe(getImporter(grokExport));
expect(() => getImporter({ conversations: [] })).toThrow('Unsupported import type');
});
it('imports every conversation on the OpenAI endpoint with per-message models', async () => {
const requestUserId = 'user-123';
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
jest.spyOn(importBatchBuilder, 'saveMessage');
jest.spyOn(importBatchBuilder, 'startConversation');
jest.spyOn(importBatchBuilder, 'finishConversation');
const importer = getImporter(grokExport);
await importer(grokExport, requestUserId, () => importBatchBuilder);
expect(importBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI);
/** Six responses, one of them an aborted generation with no text. */
expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(5);
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
const userMsg = savedMessages.find((msg) => msg.text === 'Where should I stay?');
expect(userMsg.isCreatedByUser).toBe(true);
expect(userMsg.sender).toBe('user');
expect(userMsg.endpoint).toBe(EModelEndpoint.openAI);
/** A blank model on a human turn falls back rather than persisting empty. */
expect(userMsg.model).toBe(openAISettings.model.default);
const upperCaseSender = savedMessages.find((msg) => msg.text === 'Positano.');
expect(upperCaseSender.isCreatedByUser).toBe(false);
expect(upperCaseSender.sender).toBe('Grok 4.1 Thinking');
expect(upperCaseSender.model).toBe('grok-4-1-thinking-1129');
expect(upperCaseSender.parentMessageId).toBe(userMsg.messageId);
const sibling = savedMessages.find((msg) => msg.text === 'Ravello.');
expect(sibling.parentMessageId).toBe(userMsg.messageId);
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
'Amalfi trip planning',
expect.any(Date),
{
isArchived: false,
pinned: false,
model: expect.any(String),
importedFrom: { source: 'grok', externalId: 'grok-branched' },
},
expect.any(String),
);
expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith(
'Recovering a cut-off script',
expect.any(Date),
expect.objectContaining({
pinned: true,
importedFrom: { source: 'grok', externalId: 'grok-retried' },
}),
expect.any(String),
);
});
});

151
api/test/grokExport.js Normal file
View file

@ -0,0 +1,151 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const JSZip = require('jszip');
const createdDirs = [];
/** The per-export uuid directory a real Grok archive nests everything under. */
const EXPORT_UUID = 'e618db0b-b890-4728-b3ec-ab25abeb068b';
const ENTRY_PATH = `ttl/30d/export_data/${EXPORT_UUID}/prod-grok-backend.json`;
/** Real Grok archives write asset paths with a doubled separator after the
* service name; mirrored so the archive layer sees production entry names. */
const ASSET_PATH = `ttl/30d/export_data/${EXPORT_UUID}/prod-mc-asset-server//0380cd07/content`;
const EXPORT = {
conversations: [
{
conversation: {
id: 'grok-branched',
title: 'Amalfi trip planning',
summary: '',
starred: false,
create_time: '2026-07-14T03:25:09.401949Z',
},
responses: [
{
/** No `parent_response_id` at all, and a blank model on a human turn. */
response: {
_id: 'g1',
sender: 'human',
model: '',
message: 'Where should I stay?',
create_time: { $date: { $numberLong: '1783999510820' } },
},
share_link: null,
},
{
/** Uppercase sender, which a real export uses on 206 responses. */
response: {
_id: 'g2a',
sender: 'ASSISTANT',
model: 'grok-4-1-thinking-1129',
parent_response_id: 'g1',
message: 'Positano.',
create_time: { $date: { $numberLong: '1783999520820' } },
thinking_trace: '<xai:tool_usage_card>\n <xai:tool_name>web_search</xai:tool_name>\n',
},
share_link: null,
},
{
response: {
_id: 'g2b',
sender: 'assistant',
model: 'grok-3',
parent_response_id: 'g1',
message: 'Ravello.',
create_time: { $date: { $numberLong: '1783999530820' } },
},
share_link: null,
},
],
},
{
conversation: {
id: 'grok-retried',
title: '',
summary: 'Recovering a cut-off script',
starred: true,
create_time: '2026-07-15T10:00:00.000000Z',
},
responses: [
{
response: {
_id: 't1',
sender: 'human',
model: 'grok-4p3-0430-v2-s320-memory',
message: 'Complete this script.',
create_time: { $date: { $numberLong: '1784102400000' } },
},
share_link: null,
},
{
/** An aborted generation with a child: skipped, child re-parented. */
response: {
_id: 't2',
sender: 'ASSISTANT',
model: 'grok-4-1-non-thinking-w-tool',
parent_response_id: 't1',
message: '',
partial: true,
create_time: { $date: { $numberLong: '1784102410000' } },
},
share_link: null,
},
{
response: {
_id: 't3',
sender: 'assistant',
model: 'grok-4-mini-thinking-tahoe',
parent_response_id: 't2',
message: 'Here is the completed script.',
create_time: { $date: { $numberLong: '1784102420000' } },
},
share_link: null,
},
],
},
],
projects: [{ workspace_id: 'w-1', name: 'New Workspace' }],
tasks: [],
media_posts: [{ id: '0380cd07', media_type: 'image', original_prompt: 'a cat' }],
};
/** The export object on its own, as a bare `prod-grok-backend.json` upload. */
function bareGrokExport() {
return Buffer.from(JSON.stringify(EXPORT));
}
/**
* Minimal Grok export archive for route-level tests: the conversation file
* nested under a per-export uuid, the two unrelated JSON files a real export
* ships beside it, and one `media_posts` binary no conversation references.
* @returns {Promise<string>} path to the written zip
*/
async function buildGrokExportZip() {
const zip = new JSZip();
zip.file(ENTRY_PATH, JSON.stringify(EXPORT));
zip.file(
`ttl/30d/export_data/${EXPORT_UUID}/prod-mc-auth-mgmt-api.json`,
JSON.stringify({ accounts: [{ email: 'fixture@example.com' }] }),
);
zip.file(`ttl/30d/export_data/${EXPORT_UUID}/prod-mc-billing.json`, JSON.stringify({}));
zip.file(ASSET_PATH, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-import-grok-route-'));
createdDirs.push(dir);
const filepath = path.join(dir, 'grok-export.zip');
fs.writeFileSync(filepath, buffer);
return filepath;
}
function cleanupGrokExportZips() {
while (createdDirs.length > 0) {
const dir = createdDirs.pop();
fs.rmSync(dir, { recursive: true, force: true });
}
}
module.exports = { bareGrokExport, buildGrokExportZip, cleanupGrokExportZips, EXPORT };

View file

@ -14,6 +14,7 @@ const SOURCE_LABELS: Partial<Record<TImportSummary['source'], TranslationKeys>>
chatgpt: 'com_ui_import_source_chatgpt',
'chatgpt-legacy': 'com_ui_import_source_chatgpt',
claude: 'com_ui_import_source_claude',
grok: 'com_ui_import_source_grok',
};
function formatAssetSize(bytes: number): string {

View file

@ -179,6 +179,26 @@ describe('Import panel', () => {
expect(screen.getByRole('heading', { name: /detected chatgpt export/i })).toHaveFocus();
});
it('names every provider it can detect, and falls back to the raw source', () => {
const labels: [TImportSummary['source'], RegExp][] = [
['chatgpt', /detected chatgpt export/i],
['chatgpt-legacy', /detected chatgpt export/i],
['claude', /detected claude export/i],
['grok', /detected grok export/i],
['librechat', /detected librechat export/i],
];
for (const [source, name] of labels) {
dataProvider.useImportJobQuery.mockReturnValue({
data: job({ summary: { ...summary(), source } }),
});
const { unmount } = render(<Import />);
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
expect(document.body.textContent).not.toMatch(/com_ui_import_source/);
unmount();
}
});
it('keeps the confirm and cancel buttons accessible while busy', () => {
dataProvider.useImportJobQuery.mockReturnValue({ data: job() });
dataProvider.useStartImportMutation.mockReturnValue({ mutate: jest.fn(), isLoading: true });

View file

@ -1305,6 +1305,7 @@
"com_ui_import_report_assets": "{{0}} attachments imported, {{1}} unavailable",
"com_ui_import_source_chatgpt": "ChatGPT",
"com_ui_import_source_claude": "Claude",
"com_ui_import_source_grok": "Grok",
"com_ui_import_stat_archived": "{{0}} archived",
"com_ui_import_stat_conversations": "{{0}} conversations",
"com_ui_import_stat_starred": "{{0}} starred",