Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion api/server/services/Config/loadCustomConfig.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
const path = require('path');
const { loadYaml, redactConfigSecretMaps, createCustomConfigLoader } = require('@librechat/api');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { syncCategories } = require('~/server/utils/agentCategory');

const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');

module.exports = createCustomConfigLoader({
const loadConfig = createCustomConfigLoader({
loadLocal: loadYaml,
defaultConfigPath,
redactConfig: redactConfigSecretMaps,
});

async function loadCustomConfig(printConfig = true, options = {}) {
const customConfig = await loadConfig(printConfig, options);
if (!customConfig) {
return customConfig;
}

// Injecting custom marketplace categories
logger.info('Checking for custom marketplace categories to sync.');
if (customConfig.interface?.marketplace?.use) {
logger.info('Marketplace is enabled in config.');
const marketplaceConfig = customConfig.interface.marketplace;
if (marketplaceConfig.categories) {
logger.info('Marketplace categories configuration found.');
// enableDefaultCategories should be set as a boolean, defaulting to true if not specified
const enableDefaultCategories =
typeof marketplaceConfig.categories.enableDefaultCategories === 'boolean'
? marketplaceConfig.categories.enableDefaultCategories
: true;
const customCategoriesList = marketplaceConfig.categories.list;
Comment thread
Educg550 marked this conversation as resolved.
if (Array.isArray(customCategoriesList)) {
logger.info(`Found ${customCategoriesList.length} custom categories to sync.`);
} else {
logger.info(
'No custom categories `list` provided; only default-category toggling will run.',
);
}
await runAsSystem(() => syncCategories(customCategoriesList, enableDefaultCategories));
}
}

return customConfig;
}

module.exports = loadCustomConfig;
35 changes: 34 additions & 1 deletion api/server/services/Config/loadCustomConfig.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ jest.mock('@librechat/data-schemas', () => {
debug: jest.fn(),
error: jest.fn(),
},
runAsSystem: jest.fn((fn) => fn()),
};
});

jest.mock('~/server/utils/agentCategory', () => ({
syncCategories: jest.fn(),
}));

const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { syncCategories } = require('~/server/utils/agentCategory');
const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider');
const loadCustomConfig = require('./loadCustomConfig');

Expand Down Expand Up @@ -121,6 +127,33 @@ describe('loadCustomConfig', () => {
expect(result).toEqual(mockConfig);
});

it('runs marketplace category sync within a system tenant context', async () => {
runAsSystem.mockImplementation((fn) => fn());
const config = {
version: '1.0',
cache: true,
interface: {
marketplace: {
use: true,
categories: {
enableDefaultCategories: true,
list: [{ value: 'Education', description: 'Educational agents.' }],
},
},
},
};
process.env.CONFIG_PATH = 'marketplaceConfig.yaml';
loadYaml.mockReturnValueOnce(config);

await loadCustomConfig();

expect(runAsSystem).toHaveBeenCalledTimes(1);
expect(syncCategories).toHaveBeenCalledWith(
[{ value: 'Education', description: 'Educational agents.' }],
true,
);
});

it('should return null and log if config schema validation fails', async () => {
const invalidConfig = { invalidField: true };
process.env.CONFIG_PATH = 'invalidConfig.yaml';
Expand Down
126 changes: 126 additions & 0 deletions api/server/utils/agentCategory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const { logger } = require('@librechat/data-schemas');

/** Mirrors the hardcoded agent fallback in `data-schemas` (agent schema default and `createAgent`).
* It must never be deactivated, or agents created without a category become unreachable from
* every marketplace tab except "All". */
const FALLBACK_CATEGORY = 'general';

function normalizeValue(raw) {
return (raw ?? '').toString().trim().toLowerCase();
}

function optionalString(raw) {
return typeof raw === 'string' ? raw.trim() : undefined;
}

/**
* Synchronizes agent categories with the database.
* @param {Array|undefined} customCategoriesList - Custom categories from config. When `undefined`, custom categories are left untouched (only defaults are toggled). An explicit empty array opts in to deleting all existing custom categories.
* @param {boolean} enableDefaultCategories - Whether default categories should be active.
*/
async function syncCategories(customCategoriesList, enableDefaultCategories) {
const { getAllCategories, createCategory, updateCategory, deleteCategory } = require('~/models');
logger.info('Syncing custom agent categories...');

const dbCategories = await getAllCategories();
logger.info(`Found ${dbCategories.length} categories in the database.`);

logger.info(
`${enableDefaultCategories ? 'Enabling' : 'Disabling'} default agent categories as per configuration.`,
);
if (!enableDefaultCategories) {
logger.warn(
`Keeping the '${FALLBACK_CATEGORY}' category active: it is the fallback assigned to agents created without a category. Declare it in the categories list to change its label or description.`,
);
}
for (const cat of dbCategories.filter((c) => !c.custom)) {
const isActive = cat.value === FALLBACK_CATEGORY ? true : enableDefaultCategories;
logger.info(`${isActive ? 'Enabling' : 'Disabling'} category: ${cat.value}`);
await updateCategory(cat.value, { isActive });
}
Comment thread
Educg550 marked this conversation as resolved.

if (!Array.isArray(customCategoriesList)) {
logger.info(
'No custom categories list provided; leaving existing custom categories untouched.',
);
return;
}

const defaultCategoriesInDb = dbCategories.filter((cat) => !cat.custom);
const initialOrder =
defaultCategoriesInDb.reduce((max, cat) => Math.max(max, cat.order || 0), 0) + 1;
logger.info(`Current order for new custom categories starts at ${initialOrder}.`);

const preparedCategories = customCategoriesList.reduce((acc, cat, index) => {
const normalizedValue = normalizeValue(cat?.value);
if (!normalizedValue) {
logger.warn(
`Skipping invalid custom category entry at index ${index}: missing or empty value.`,
);
return acc;
}
acc.push({
normalizedValue,
rawValue: cat.value.toString().trim(),
label: optionalString(cat.label) || undefined,
description: optionalString(cat.description),
order: initialOrder + acc.length,
});
return acc;
}, []);
Comment thread
Educg550 marked this conversation as resolved.

const configuredValues = new Set(preparedCategories.map((c) => c.normalizedValue));
const categoriesToDelete = dbCategories.filter(
(cat) => cat.custom && !configuredValues.has(normalizeValue(cat.value)),
);
for (const cat of categoriesToDelete) {
logger.info(`Deleting custom category not in config: ${cat.value}`);
await deleteCategory(cat.value);
}

for (const prepared of preparedCategories) {
const existing = dbCategories.find(
(cat) => normalizeValue(cat.value) === prepared.normalizedValue,
);

/* Default categories keep `custom: false` when overridden, so the deletion pass above (which
* only targets custom rows) can never remove them, and `ensureDefaultCategories` keeps
* managing their localization keys. Only explicitly provided fields are applied — otherwise
* re-declaring a default purely to reactivate it would clobber its `com_` label. */
if (existing && !existing.custom) {
const overrides = { isActive: true };
if (prepared.label) {
overrides.label = prepared.label;
}
if (prepared.description !== undefined) {
overrides.description = prepared.description;
}
logger.info(`Overriding default category: ${existing.value}`);
await updateCategory(existing.value, overrides);
continue;
}

const formattedCategory = {
value: prepared.normalizedValue,
Comment thread
Educg550 marked this conversation as resolved.
label: prepared.label || prepared.rawValue,
description: prepared.description ?? '',
isActive: true,
custom: true,
order: prepared.order,
};
Comment thread
Educg550 marked this conversation as resolved.

if (existing) {
logger.info(`Updating category: ${formattedCategory.value}`);
await updateCategory(existing.value, formattedCategory);
} else {
logger.info(`Creating category: ${formattedCategory.value}`);
await createCategory(formattedCategory);
}
}

logger.info('Custom categories synchronized successfully.');
}

module.exports = {
syncCategories,
};
Loading