e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
540 lines
19 KiB
JavaScript
540 lines
19 KiB
JavaScript
const { Constants, actionDelimiter, actionDomainSeparator } = require('librechat-data-provider');
|
|
const { domainParser, legacyDomainEncode, validateAndUpdateTool } = require('./ActionService');
|
|
|
|
jest.mock('keyv');
|
|
|
|
jest.mock('~/models', () => ({
|
|
getActions: jest.fn(),
|
|
deleteActions: jest.fn(),
|
|
}));
|
|
|
|
const { getActions } = require('~/models');
|
|
|
|
let mockDomainCache = {};
|
|
jest.mock('~/cache/getLogStores', () => {
|
|
return jest.fn().mockImplementation(() => ({
|
|
get: async (key) => mockDomainCache[key] ?? null,
|
|
set: async (key, value) => {
|
|
mockDomainCache[key] = value;
|
|
return true;
|
|
},
|
|
}));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockDomainCache = {};
|
|
getActions.mockReset();
|
|
});
|
|
|
|
const SEP = actionDomainSeparator;
|
|
const DELIM = actionDelimiter;
|
|
const MAX = Constants.ENCODED_DOMAIN_LENGTH;
|
|
const domainSepRegex = new RegExp(SEP, 'g');
|
|
|
|
describe('domainParser', () => {
|
|
describe('nullish input', () => {
|
|
it.each([null, undefined, ''])('returns undefined for %j', async (input) => {
|
|
expect(await domainParser(input, true)).toBeUndefined();
|
|
expect(await domainParser(input, false)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('short-path encoding (hostname ≤ threshold)', () => {
|
|
it.each([
|
|
['examp.com', `examp${SEP}com`],
|
|
['swapi.tech', `swapi${SEP}tech`],
|
|
['a.b', `a${SEP}b`],
|
|
])('replaces dots in %s → %s', async (domain, expected) => {
|
|
expect(await domainParser(domain, true)).toBe(expected);
|
|
});
|
|
|
|
it('handles domain exactly at threshold length', async () => {
|
|
const domain = 'a'.repeat(MAX - 4) + '.com';
|
|
expect(domain).toHaveLength(MAX);
|
|
const result = await domainParser(domain, true);
|
|
expect(result).toBe(domain.replace(/\./g, SEP));
|
|
});
|
|
});
|
|
|
|
describe('base64-path encoding (hostname > threshold)', () => {
|
|
it('produces a key of exactly ENCODED_DOMAIN_LENGTH chars', async () => {
|
|
const result = await domainParser('api.example.com', true);
|
|
expect(result).toHaveLength(MAX);
|
|
});
|
|
|
|
it('encodes hostname, not full URL', async () => {
|
|
const hostname = 'api.example.com';
|
|
const expectedKey = Buffer.from(hostname).toString('base64').substring(0, MAX);
|
|
expect(await domainParser(hostname, true)).toBe(expectedKey);
|
|
});
|
|
|
|
it('populates decode cache for round-trip', async () => {
|
|
const hostname = 'longdomainname.com';
|
|
const key = await domainParser(hostname, true);
|
|
|
|
expect(mockDomainCache[key]).toBe(Buffer.from(hostname).toString('base64'));
|
|
expect(await domainParser(key, false)).toBe(hostname);
|
|
});
|
|
});
|
|
|
|
describe('protocol stripping', () => {
|
|
it('https:// URL and bare hostname produce identical encoding', async () => {
|
|
const encoded = await domainParser('https://swapi.tech', true);
|
|
expect(encoded).toBe(await domainParser('swapi.tech', true));
|
|
expect(encoded).toBe(`swapi${SEP}tech`);
|
|
});
|
|
|
|
it('http:// URL and bare hostname produce identical encoding', async () => {
|
|
const encoded = await domainParser('http://api.example.com', true);
|
|
expect(encoded).toBe(await domainParser('api.example.com', true));
|
|
});
|
|
|
|
it('different https:// domains produce unique keys', async () => {
|
|
const keys = await Promise.all([
|
|
domainParser('https://api.example.com', true),
|
|
domainParser('https://api.weather.com', true),
|
|
domainParser('https://data.github.com', true),
|
|
]);
|
|
const unique = new Set(keys);
|
|
expect(unique.size).toBe(keys.length);
|
|
});
|
|
|
|
it('long hostname after stripping still uses base64 path', async () => {
|
|
const result = await domainParser('https://api.example.com', true);
|
|
expect(result).toHaveLength(MAX);
|
|
expect(result).not.toContain(SEP);
|
|
});
|
|
|
|
it('short hostname after stripping uses dot-replacement path', async () => {
|
|
const result = await domainParser('https://a.b.c', true);
|
|
expect(result).toBe(`a${SEP}b${SEP}c`);
|
|
});
|
|
|
|
it('strips path and query from full URL before encoding', async () => {
|
|
const result = await domainParser('https://api.example.com/v1/endpoint?foo=bar', true);
|
|
expect(result).toBe(await domainParser('api.example.com', true));
|
|
});
|
|
});
|
|
|
|
describe('unicode domains', () => {
|
|
it('encodes unicode hostname via base64 path', async () => {
|
|
const domain = 'täst.example.com';
|
|
const result = await domainParser(domain, true);
|
|
expect(result).toHaveLength(MAX);
|
|
expect(result).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
|
|
});
|
|
|
|
it('round-trips unicode hostname through encode then decode', async () => {
|
|
const domain = 'täst.example.com';
|
|
const key = await domainParser(domain, true);
|
|
expect(await domainParser(key, false)).toBe(domain);
|
|
});
|
|
|
|
it('strips protocol before encoding unicode hostname', async () => {
|
|
const withProto = 'https://täst.example.com';
|
|
const bare = 'täst.example.com';
|
|
expect(await domainParser(withProto, true)).toBe(await domainParser(bare, true));
|
|
});
|
|
});
|
|
|
|
describe('decode path', () => {
|
|
it('short-path encoded domain decodes via separator replacement', async () => {
|
|
expect(await domainParser(`examp${SEP}com`, false)).toBe('examp.com');
|
|
});
|
|
|
|
it('base64-path encoded domain decodes via cache lookup', async () => {
|
|
const hostname = 'api.example.com';
|
|
const key = await domainParser(hostname, true);
|
|
expect(await domainParser(key, false)).toBe(hostname);
|
|
});
|
|
|
|
it('returns input unchanged for unknown non-separator strings', async () => {
|
|
expect(await domainParser('not_base64_encoded', false)).toBe('not_base64_encoded');
|
|
});
|
|
|
|
it('returns a string without throwing for corrupt cache entries', async () => {
|
|
mockDomainCache['corrupt_key'] = '!!!';
|
|
const result = await domainParser('corrupt_key', false);
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('legacyDomainEncode', () => {
|
|
it.each(['', null, undefined])('returns empty string for %j', (input) => {
|
|
expect(legacyDomainEncode(input)).toBe('');
|
|
});
|
|
|
|
it('is synchronous (returns a string, not a Promise)', () => {
|
|
const result = legacyDomainEncode('examp.com');
|
|
expect(result).toBe(`examp${SEP}com`);
|
|
expect(result).not.toBeInstanceOf(Promise);
|
|
});
|
|
|
|
it('uses dot-replacement for short domains', () => {
|
|
expect(legacyDomainEncode('examp.com')).toBe(`examp${SEP}com`);
|
|
});
|
|
|
|
it('uses base64 prefix of full input for long domains', () => {
|
|
const domain = 'https://swapi.tech';
|
|
const expected = Buffer.from(domain).toString('base64').substring(0, MAX);
|
|
expect(legacyDomainEncode(domain)).toBe(expected);
|
|
});
|
|
|
|
it('all https:// URLs collide to the same key', () => {
|
|
const results = [
|
|
legacyDomainEncode('https://api.example.com'),
|
|
legacyDomainEncode('https://api.weather.com'),
|
|
legacyDomainEncode('https://totally.different.host'),
|
|
];
|
|
expect(new Set(results).size).toBe(1);
|
|
});
|
|
|
|
it('matches what old domainParser would have produced', () => {
|
|
const domain = 'https://api.example.com';
|
|
const legacy = legacyDomainEncode(domain);
|
|
expect(legacy).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
|
|
});
|
|
|
|
it('produces same result as new domainParser for short bare hostnames', async () => {
|
|
const domain = 'swapi.tech';
|
|
expect(legacyDomainEncode(domain)).toBe(await domainParser(domain, true));
|
|
});
|
|
});
|
|
|
|
describe('validateAndUpdateTool', () => {
|
|
const mockReq = { user: { id: 'user123' } };
|
|
|
|
it('returns tool unchanged when name passes tool-name regex', async () => {
|
|
const tool = { function: { name: 'getPeople_action_swapi---tech' } };
|
|
const result = await validateAndUpdateTool({
|
|
req: mockReq,
|
|
tool,
|
|
assistant_id: 'asst_1',
|
|
});
|
|
expect(result).toEqual(tool);
|
|
expect(getActions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('matches action when metadata.domain has https:// prefix and tool domain is bare hostname', async () => {
|
|
getActions.mockResolvedValue([{ metadata: { domain: 'https://api.example.com' } }]);
|
|
|
|
const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
|
|
const result = await validateAndUpdateTool({
|
|
req: mockReq,
|
|
tool,
|
|
assistant_id: 'asst_1',
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result.function.name).toMatch(/^getPeople_action_/);
|
|
expect(result.function.name).not.toContain('.');
|
|
});
|
|
|
|
it('matches action when metadata.domain has no protocol', async () => {
|
|
getActions.mockResolvedValue([{ metadata: { domain: 'api.example.com' } }]);
|
|
|
|
const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
|
|
const result = await validateAndUpdateTool({
|
|
req: mockReq,
|
|
tool,
|
|
assistant_id: 'asst_1',
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result.function.name).toMatch(/^getPeople_action_/);
|
|
});
|
|
|
|
it('returns null when no action matches the domain', async () => {
|
|
getActions.mockResolvedValue([{ metadata: { domain: 'https://other.domain.com' } }]);
|
|
|
|
const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
|
|
const result = await validateAndUpdateTool({
|
|
req: mockReq,
|
|
tool,
|
|
assistant_id: 'asst_1',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when action has no metadata', async () => {
|
|
getActions.mockResolvedValue([{ metadata: null }]);
|
|
|
|
const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
|
|
const result = await validateAndUpdateTool({
|
|
req: mockReq,
|
|
tool,
|
|
assistant_id: 'asst_1',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('backward-compatible tool name matching', () => {
|
|
function normalizeToolName(name) {
|
|
return name.replace(domainSepRegex, '_');
|
|
}
|
|
|
|
function buildToolName(functionName, encodedDomain) {
|
|
return `${functionName}${DELIM}${encodedDomain}`;
|
|
}
|
|
|
|
describe('definition-phase matching', () => {
|
|
it('new encoding matches agent tools stored with new encoding', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const encoded = await domainParser(metadataDomain, true);
|
|
const normalized = normalizeToolName(encoded);
|
|
|
|
const storedTool = buildToolName('getPeople', encoded);
|
|
const defToolName = `getPeople${DELIM}${normalized}`;
|
|
|
|
expect(normalizeToolName(storedTool)).toBe(defToolName);
|
|
});
|
|
|
|
it('legacy encoding matches agent tools stored with legacy encoding', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const legacy = legacyDomainEncode(metadataDomain);
|
|
const legacyNormalized = normalizeToolName(legacy);
|
|
|
|
const storedTool = buildToolName('getPeople', legacy);
|
|
const legacyDefName = `getPeople${DELIM}${legacyNormalized}`;
|
|
|
|
expect(normalizeToolName(storedTool)).toBe(legacyDefName);
|
|
});
|
|
|
|
it('new definition matches old stored tools via legacy fallback', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const newDomain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
const newNorm = normalizeToolName(newDomain);
|
|
const legacyNorm = normalizeToolName(legacyDomain);
|
|
|
|
const oldStoredTool = buildToolName('getPeople', legacyDomain);
|
|
const newToolName = `getPeople${DELIM}${newNorm}`;
|
|
const legacyToolName = `getPeople${DELIM}${legacyNorm}`;
|
|
|
|
const storedNormalized = normalizeToolName(oldStoredTool);
|
|
const hasMatch = storedNormalized === newToolName || storedNormalized === legacyToolName;
|
|
expect(hasMatch).toBe(true);
|
|
});
|
|
|
|
it('pre-normalized Set eliminates per-tool normalization', async () => {
|
|
const metadataDomain = 'https://api.example.com';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
const normalizedDomain = normalizeToolName(domain);
|
|
const legacyNormalized = normalizeToolName(legacyDomain);
|
|
|
|
const storedTools = [
|
|
buildToolName('getWeather', legacyDomain),
|
|
buildToolName('getForecast', domain),
|
|
];
|
|
|
|
const preNormalized = new Set(storedTools.map((t) => normalizeToolName(t)));
|
|
|
|
const toolName = `getWeather${DELIM}${normalizedDomain}`;
|
|
const legacyToolName = `getWeather${DELIM}${legacyNormalized}`;
|
|
expect(preNormalized.has(toolName) || preNormalized.has(legacyToolName)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('execution-phase tool lookup', () => {
|
|
it('model-called tool name resolves via normalizedToDomain map (new encoding)', async () => {
|
|
const metadataDomain = 'https://api.example.com';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const normalized = normalizeToolName(domain);
|
|
|
|
const normalizedToDomain = new Map();
|
|
normalizedToDomain.set(normalized, domain);
|
|
|
|
const modelToolName = `getWeather${DELIM}${normalized}`;
|
|
|
|
let matched = '';
|
|
for (const [norm, canonical] of normalizedToDomain.entries()) {
|
|
if (modelToolName.includes(norm)) {
|
|
matched = canonical;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(matched).toBe(domain);
|
|
|
|
const functionName = modelToolName.replace(`${DELIM}${normalizeToolName(matched)}`, '');
|
|
expect(functionName).toBe('getWeather');
|
|
});
|
|
|
|
it('model-called tool name resolves via legacy entry in normalizedToDomain map', async () => {
|
|
const metadataDomain = 'https://api.example.com';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
const legacyNorm = normalizeToolName(legacyDomain);
|
|
|
|
const normalizedToDomain = new Map();
|
|
normalizedToDomain.set(normalizeToolName(domain), domain);
|
|
normalizedToDomain.set(legacyNorm, domain);
|
|
|
|
const legacyModelToolName = `getWeather${DELIM}${legacyNorm}`;
|
|
|
|
let matched = '';
|
|
for (const [norm, canonical] of normalizedToDomain.entries()) {
|
|
if (legacyModelToolName.includes(norm)) {
|
|
matched = canonical;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(matched).toBe(domain);
|
|
});
|
|
|
|
it('legacy guard skips duplicate map entry for short bare hostnames', async () => {
|
|
const domain = 'swapi.tech';
|
|
const newEncoding = await domainParser(domain, true);
|
|
const legacyEncoding = legacyDomainEncode(domain);
|
|
|
|
expect(newEncoding).toBe(legacyEncoding);
|
|
|
|
const normalizedToDomain = new Map();
|
|
normalizedToDomain.set(newEncoding, newEncoding);
|
|
if (legacyEncoding !== newEncoding) {
|
|
normalizedToDomain.set(legacyEncoding, newEncoding);
|
|
}
|
|
expect(normalizedToDomain.size).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('processRequiredActions matching (assistants path)', () => {
|
|
it('legacy tool from OpenAI matches via normalizedToDomain with both encodings', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
|
|
const normalizedToDomain = new Map();
|
|
normalizedToDomain.set(domain, domain);
|
|
if (legacyDomain !== domain) {
|
|
normalizedToDomain.set(legacyDomain, domain);
|
|
}
|
|
|
|
const legacyToolName = buildToolName('getPeople', legacyDomain);
|
|
|
|
let currentDomain = '';
|
|
let matchedKey = '';
|
|
for (const [key, canonical] of normalizedToDomain.entries()) {
|
|
if (legacyToolName.includes(key)) {
|
|
currentDomain = canonical;
|
|
matchedKey = key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(currentDomain).toBe(domain);
|
|
expect(matchedKey).toBe(legacyDomain);
|
|
|
|
const functionName = legacyToolName.replace(`${DELIM}${matchedKey}`, '');
|
|
expect(functionName).toBe('getPeople');
|
|
});
|
|
|
|
it('new tool name matches via the canonical domain key', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
|
|
const normalizedToDomain = new Map();
|
|
normalizedToDomain.set(domain, domain);
|
|
if (legacyDomain !== domain) {
|
|
normalizedToDomain.set(legacyDomain, domain);
|
|
}
|
|
|
|
const newToolName = buildToolName('getPeople', domain);
|
|
|
|
let currentDomain = '';
|
|
let matchedKey = '';
|
|
for (const [key, canonical] of normalizedToDomain.entries()) {
|
|
if (newToolName.includes(key)) {
|
|
currentDomain = canonical;
|
|
matchedKey = key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(currentDomain).toBe(domain);
|
|
expect(matchedKey).toBe(domain);
|
|
|
|
const functionName = newToolName.replace(`${DELIM}${matchedKey}`, '');
|
|
expect(functionName).toBe('getPeople');
|
|
});
|
|
});
|
|
|
|
describe('save-route cleanup', () => {
|
|
it('tool filter removes tools matching new encoding', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
|
|
const tools = [
|
|
buildToolName('getPeople', domain),
|
|
buildToolName('unrelated', 'other---domain'),
|
|
];
|
|
|
|
const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));
|
|
|
|
expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
|
|
});
|
|
|
|
it('tool filter removes tools matching legacy encoding', async () => {
|
|
const metadataDomain = 'https://swapi.tech';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const legacyDomain = legacyDomainEncode(metadataDomain);
|
|
|
|
const tools = [
|
|
buildToolName('getPeople', legacyDomain),
|
|
buildToolName('unrelated', 'other---domain'),
|
|
];
|
|
|
|
const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));
|
|
|
|
expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
|
|
});
|
|
});
|
|
|
|
describe('delete-route domain extraction', () => {
|
|
it('domain extracted from actions array is usable as-is for tool filtering', async () => {
|
|
const metadataDomain = 'https://api.example.com';
|
|
const domain = await domainParser(metadataDomain, true);
|
|
const actionId = 'abc123';
|
|
const actionEntry = `${domain}${DELIM}${actionId}`;
|
|
|
|
const [storedDomain] = actionEntry.split(DELIM);
|
|
expect(storedDomain).toBe(domain);
|
|
|
|
const tools = [buildToolName('getWeather', domain), buildToolName('getPeople', 'other')];
|
|
|
|
const filtered = tools.filter((t) => !t.includes(storedDomain));
|
|
expect(filtered).toEqual([buildToolName('getPeople', 'other')]);
|
|
});
|
|
});
|
|
|
|
describe('multi-action agents (collision scenario)', () => {
|
|
it('two https:// actions now produce distinct tool names', async () => {
|
|
const domain1 = await domainParser('https://api.weather.com', true);
|
|
const domain2 = await domainParser('https://api.spacex.com', true);
|
|
|
|
const tool1 = buildToolName('getData', domain1);
|
|
const tool2 = buildToolName('getData', domain2);
|
|
|
|
expect(tool1).not.toBe(tool2);
|
|
});
|
|
|
|
it('two https:// actions used to collide in legacy encoding', () => {
|
|
const legacy1 = legacyDomainEncode('https://api.weather.com');
|
|
const legacy2 = legacyDomainEncode('https://api.spacex.com');
|
|
|
|
const tool1 = buildToolName('getData', legacy1);
|
|
const tool2 = buildToolName('getData', legacy2);
|
|
|
|
expect(tool1).toBe(tool2);
|
|
});
|
|
});
|
|
});
|