mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-18 16:38:10 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
af661b1df2
293 changed files with 20207 additions and 13884 deletions
|
|
@ -9,6 +9,8 @@ import {
|
|||
ActionRequest,
|
||||
openapiToFunction,
|
||||
FunctionSignature,
|
||||
extractDomainFromUrl,
|
||||
validateActionDomain,
|
||||
validateAndParseOpenAPISpec,
|
||||
} from '../src/actions';
|
||||
import {
|
||||
|
|
@ -1478,3 +1480,362 @@ describe('createURL', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSRF Protection', () => {
|
||||
describe('extractDomainFromUrl', () => {
|
||||
it('extracts domain from valid HTTPS URL', () => {
|
||||
expect(extractDomainFromUrl('https://example.com')).toBe('https://example.com');
|
||||
expect(extractDomainFromUrl('https://example.com/path')).toBe('https://example.com');
|
||||
expect(extractDomainFromUrl('https://example.com:8080')).toBe('https://example.com');
|
||||
expect(extractDomainFromUrl('https://example.com:8080/path?query=value')).toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts domain from valid HTTP URL', () => {
|
||||
expect(extractDomainFromUrl('http://example.com')).toBe('http://example.com');
|
||||
expect(extractDomainFromUrl('http://example.com/api')).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('handles subdomains correctly', () => {
|
||||
expect(extractDomainFromUrl('https://api.example.com')).toBe('https://api.example.com');
|
||||
expect(extractDomainFromUrl('https://subdomain.api.example.com/path')).toBe(
|
||||
'https://subdomain.api.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error for invalid URLs', () => {
|
||||
expect(() => extractDomainFromUrl('not-a-url')).toThrow('Invalid URL format');
|
||||
expect(() => extractDomainFromUrl('')).toThrow('Invalid URL format');
|
||||
expect(() => extractDomainFromUrl('example.com')).toThrow('Invalid URL format');
|
||||
});
|
||||
|
||||
it('preserves protocol to prevent HTTP/HTTPS confusion', () => {
|
||||
const httpsDomain = extractDomainFromUrl('https://example.com/path');
|
||||
const httpDomain = extractDomainFromUrl('http://example.com/path');
|
||||
expect(httpsDomain).not.toBe(httpDomain);
|
||||
expect(httpsDomain).toBe('https://example.com');
|
||||
expect(httpDomain).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('handles internal/private IP addresses', () => {
|
||||
expect(extractDomainFromUrl('http://192.168.1.1')).toBe('http://192.168.1.1');
|
||||
expect(extractDomainFromUrl('http://10.0.0.1/admin')).toBe('http://10.0.0.1');
|
||||
expect(extractDomainFromUrl('http://172.16.0.1')).toBe('http://172.16.0.1');
|
||||
expect(extractDomainFromUrl('http://127.0.0.1:8080')).toBe('http://127.0.0.1');
|
||||
});
|
||||
|
||||
it('handles cloud metadata service URLs', () => {
|
||||
// AWS EC2 metadata
|
||||
expect(extractDomainFromUrl('http://169.254.169.254/latest/meta-data/')).toBe(
|
||||
'http://169.254.169.254',
|
||||
);
|
||||
// Google Cloud metadata
|
||||
expect(extractDomainFromUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(
|
||||
'http://metadata.google.internal',
|
||||
);
|
||||
// Azure metadata
|
||||
expect(extractDomainFromUrl('http://169.254.169.254/metadata/instance')).toBe(
|
||||
'http://169.254.169.254',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndParseOpenAPISpec - SSRF Prevention', () => {
|
||||
it('returns serverUrl for valid spec', () => {
|
||||
const validSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'https://example.com' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(validSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('extracts serverUrl even with path in server URL', () => {
|
||||
const specWithPath = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'https://example.com/api/v1' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(specWithPath);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('https://example.com/api/v1');
|
||||
});
|
||||
|
||||
it('detects potential SSRF attempts with internal IPs', () => {
|
||||
const internalIPSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://192.168.1.1' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(internalIPSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://192.168.1.1');
|
||||
});
|
||||
|
||||
it('detects potential SSRF attempts with localhost', () => {
|
||||
const localhostSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://localhost:8080' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(localhostSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('detects potential SSRF attempts with cloud metadata services', () => {
|
||||
const awsMetadataSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(awsMetadataSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/');
|
||||
});
|
||||
|
||||
it('handles multiple servers and returns the first one', () => {
|
||||
const multiServerSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Test API', version: '1.0.0' },
|
||||
servers: [{ url: 'https://api.example.com' }, { url: 'https://backup.example.com' }],
|
||||
paths: { '/test': {} },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(multiServerSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSRF Attack Scenarios', () => {
|
||||
it('scenario: attacker tries to use whitelisted domain but different spec URL', () => {
|
||||
const maliciousSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Malicious API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }], // AWS metadata service
|
||||
paths: { '/': { get: { summary: 'Get metadata', operationId: 'getMetadata' } } },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(maliciousSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/');
|
||||
|
||||
// The fix ensures this serverUrl would be validated against the domain whitelist
|
||||
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
||||
expect(extractedDomain).toBe('http://169.254.169.254');
|
||||
|
||||
// In the actual validation, this would not match a whitelisted 'example.com'
|
||||
expect(extractedDomain).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('scenario: attacker tries to use internal network IP', () => {
|
||||
const internalNetworkSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Internal API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://10.0.0.1:8080/admin' }],
|
||||
paths: { '/': { get: { summary: 'Admin endpoint', operationId: 'getAdmin' } } },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(internalNetworkSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://10.0.0.1:8080/admin');
|
||||
|
||||
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
||||
expect(extractedDomain).toBe('http://10.0.0.1');
|
||||
expect(extractedDomain).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('scenario: attacker tries to access Google Cloud metadata', () => {
|
||||
const gcpMetadataSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'GCP Metadata', version: '1.0.0' },
|
||||
servers: [{ url: 'http://metadata.google.internal/computeMetadata/v1/' }],
|
||||
paths: { '/': { get: { summary: 'Get GCP metadata', operationId: 'getGCPMetadata' } } },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(gcpMetadataSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://metadata.google.internal/computeMetadata/v1/');
|
||||
|
||||
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
||||
expect(extractedDomain).toBe('http://metadata.google.internal');
|
||||
expect(extractedDomain).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('scenario: legitimate use case with correct domain matching', () => {
|
||||
const legitimateSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Legitimate API', version: '1.0.0' },
|
||||
servers: [{ url: 'https://api.example.com/v1' }],
|
||||
paths: { '/data': { get: { summary: 'Get data', operationId: 'getData' } } },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(legitimateSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('https://api.example.com/v1');
|
||||
|
||||
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
||||
expect(extractedDomain).toBe('https://api.example.com');
|
||||
|
||||
// This should match when client provides 'api.example.com' or 'https://api.example.com'
|
||||
const clientProvidedDomain = 'api.example.com';
|
||||
const normalizedClientDomain = `https://${clientProvidedDomain}`;
|
||||
expect(extractedDomain).toBe(normalizedClientDomain);
|
||||
});
|
||||
|
||||
it('scenario: protocol mismatch should be detected', () => {
|
||||
const httpSpec = JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'HTTP API', version: '1.0.0' },
|
||||
servers: [{ url: 'http://example.com' }],
|
||||
paths: { '/': { get: { summary: 'Get data', operationId: 'getData' } } },
|
||||
});
|
||||
|
||||
const result = validateAndParseOpenAPISpec(httpSpec);
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.serverUrl).toBe('http://example.com');
|
||||
|
||||
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
||||
expect(extractedDomain).toBe('http://example.com');
|
||||
|
||||
// If client provided 'https://example.com', there would be a mismatch
|
||||
const clientProvidedHttps = 'https://example.com';
|
||||
expect(extractedDomain).not.toBe(clientProvidedHttps);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateActionDomain', () => {
|
||||
it('validates matching domains with HTTPS protocol', () => {
|
||||
const result = validateActionDomain('example.com', 'https://example.com/api/v1');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
||||
expect(result.normalizedClientDomain).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('validates matching domains when client provides full URL', () => {
|
||||
const result = validateActionDomain('https://example.com', 'https://example.com/api');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
||||
expect(result.normalizedClientDomain).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('rejects mismatched domains', () => {
|
||||
const result = validateActionDomain('example.com', 'https://malicious.com/api');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
expect(result.message).toContain('example.com');
|
||||
expect(result.message).toContain('https://malicious.com');
|
||||
});
|
||||
|
||||
it('detects SSRF attempt with internal IP', () => {
|
||||
const result = validateActionDomain('example.com', 'http://192.168.1.1/admin');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
|
||||
});
|
||||
|
||||
it('detects SSRF attempt with AWS metadata service', () => {
|
||||
const result = validateActionDomain(
|
||||
'api.example.com',
|
||||
'http://169.254.169.254/latest/meta-data/',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
expect(result.normalizedSpecDomain).toBe('http://169.254.169.254');
|
||||
});
|
||||
|
||||
it('detects SSRF attempt with localhost', () => {
|
||||
const result = validateActionDomain('example.com', 'http://localhost:8080/api');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
expect(result.normalizedSpecDomain).toBe('http://localhost');
|
||||
});
|
||||
|
||||
it('detects protocol mismatch (HTTP vs HTTPS)', () => {
|
||||
const result = validateActionDomain('https://example.com', 'http://example.com/api');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
||||
expect(result.normalizedClientDomain).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('validates matching subdomains', () => {
|
||||
const result = validateActionDomain('api.example.com', 'https://api.example.com/v1');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('rejects different subdomains', () => {
|
||||
const result = validateActionDomain('api.example.com', 'https://admin.example.com/v1');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Domain mismatch');
|
||||
});
|
||||
|
||||
it('handles invalid server URL gracefully', () => {
|
||||
const result = validateActionDomain('example.com', 'not-a-valid-url');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.message).toContain('Failed to validate domain');
|
||||
});
|
||||
|
||||
it('validates with port numbers', () => {
|
||||
const result = validateActionDomain('example.com', 'https://example.com:8443/api');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('detects port-based SSRF attempt', () => {
|
||||
const result = validateActionDomain('example.com', 'http://example.com:6379/');
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
||||
expect(result.normalizedClientDomain).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('validates Google Cloud metadata service detection', () => {
|
||||
const result = validateActionDomain(
|
||||
'example.com',
|
||||
'http://metadata.google.internal/computeMetadata/v1/',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.normalizedSpecDomain).toBe('http://metadata.google.internal');
|
||||
});
|
||||
|
||||
it('validates Azure metadata service detection', () => {
|
||||
const result = validateActionDomain(
|
||||
'example.com',
|
||||
'http://169.254.169.254/metadata/instance',
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.normalizedSpecDomain).toBe('http://169.254.169.254');
|
||||
});
|
||||
|
||||
it('handles edge case: client provides domain with protocol matching spec', () => {
|
||||
const result = validateActionDomain('http://example.com', 'http://example.com/api');
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
||||
expect(result.normalizedClientDomain).toBe('http://example.com');
|
||||
});
|
||||
|
||||
it('validates real-world case: legitimate API with versioned path', () => {
|
||||
const result = validateActionDomain(
|
||||
'api.openai.com',
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.normalizedSpecDomain).toBe('https://api.openai.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -563,8 +563,83 @@ export type ValidationResult = {
|
|||
status: boolean;
|
||||
message: string;
|
||||
spec?: OpenAPIV3.Document;
|
||||
serverUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the domain from a URL string.
|
||||
* @param {string} url - The URL to extract the domain from.
|
||||
* @returns {string} The extracted domain (hostname with protocol).
|
||||
*/
|
||||
export function extractDomainFromUrl(url: string): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// Return protocol + hostname (e.g., "https://example.com")
|
||||
// This preserves the protocol which is important for SSRF prevention
|
||||
return `${parsedUrl.protocol}//${parsedUrl.hostname}`;
|
||||
} catch {
|
||||
throw new Error(`Invalid URL format: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type DomainValidationResult = {
|
||||
isValid: boolean;
|
||||
message?: string;
|
||||
normalizedSpecDomain?: string;
|
||||
normalizedClientDomain?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a client-provided domain matches the domain from an OpenAPI spec server URL.
|
||||
* This is critical for preventing SSRF attacks where an attacker provides a whitelisted domain
|
||||
* but uses a different (potentially internal) URL in the raw OpenAPI spec.
|
||||
*
|
||||
* @param {string} clientProvidedDomain - The domain provided by the client (may or may not include protocol)
|
||||
* @param {string} specServerUrl - The server URL from the OpenAPI spec
|
||||
* @returns {DomainValidationResult} Validation result with normalized domains
|
||||
*/
|
||||
export function validateActionDomain(
|
||||
clientProvidedDomain: string,
|
||||
specServerUrl: string,
|
||||
): DomainValidationResult {
|
||||
try {
|
||||
// Extract domain from the spec's server URL
|
||||
const specDomain = extractDomainFromUrl(specServerUrl);
|
||||
const normalizedSpecDomain = extractDomainFromUrl(specDomain);
|
||||
|
||||
// Normalize client-provided domain (add https:// if no protocol)
|
||||
const normalizedClientDomain = clientProvidedDomain.startsWith('http')
|
||||
? clientProvidedDomain
|
||||
: `https://${clientProvidedDomain}`;
|
||||
|
||||
// Compare normalized domains
|
||||
// We check both the normalized client domain and the raw client domain
|
||||
// to handle cases where the client might provide "example.com" vs "https://example.com"
|
||||
if (
|
||||
normalizedSpecDomain !== normalizedClientDomain &&
|
||||
normalizedSpecDomain !== clientProvidedDomain
|
||||
) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`,
|
||||
normalizedSpecDomain,
|
||||
normalizedClientDomain,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
normalizedSpecDomain,
|
||||
normalizedClientDomain,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `Failed to validate domain: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and parses an OpenAPI spec.
|
||||
*/
|
||||
|
|
@ -626,6 +701,7 @@ export function validateAndParseOpenAPISpec(specString: string): ValidationResul
|
|||
status: true,
|
||||
message: messages.join('\n') || 'OpenAPI spec is valid.',
|
||||
spec: parsedSpec,
|
||||
serverUrl: parsedSpec.servers[0].url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export const messages = (params: q.MessagesListParams) => {
|
|||
return `${messagesRoot}${buildQuery(rest)}`;
|
||||
};
|
||||
|
||||
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifacts/${messageId}`;
|
||||
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifact/${messageId}`;
|
||||
|
||||
const shareRoot = `${BASE_URL}/api/share`;
|
||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export const bedrockInputSchema = s.tConversationSchema
|
|||
stop: true,
|
||||
thinking: true,
|
||||
thinkingBudget: true,
|
||||
promptCache: true,
|
||||
/* Catch-all fields */
|
||||
topK: true,
|
||||
additionalModelRequestFields: true,
|
||||
|
|
@ -78,6 +79,7 @@ export const bedrockInputParser = s.tConversationSchema
|
|||
stop: true,
|
||||
thinking: true,
|
||||
thinkingBudget: true,
|
||||
promptCache: true,
|
||||
/* Catch-all fields */
|
||||
topK: true,
|
||||
additionalModelRequestFields: true,
|
||||
|
|
@ -100,6 +102,7 @@ export const bedrockInputParser = s.tConversationSchema
|
|||
'temperature',
|
||||
'topP',
|
||||
'stop',
|
||||
'promptCache',
|
||||
];
|
||||
|
||||
const additionalFields: Record<string, unknown> = {};
|
||||
|
|
@ -140,6 +143,15 @@ export const bedrockInputParser = s.tConversationSchema
|
|||
delete additionalFields.thinkingBudget;
|
||||
}
|
||||
|
||||
/** Default promptCache for claude and nova models, if not defined */
|
||||
if (
|
||||
typeof typedData.model === 'string' &&
|
||||
(typedData.model.includes('claude') || typedData.model.includes('nova')) &&
|
||||
typedData.promptCache === undefined
|
||||
) {
|
||||
typedData.promptCache = true;
|
||||
}
|
||||
|
||||
if (Object.keys(additionalFields).length > 0) {
|
||||
typedData.additionalModelRequestFields = {
|
||||
...((typedData.additionalModelRequestFields as Record<string, unknown> | undefined) || {}),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
import type { ZodError } from 'zod';
|
||||
import type { TModelsConfig } from './types';
|
||||
import type { TEndpointsConfig, TModelsConfig, TConfig } from './types';
|
||||
import { EModelEndpoint, eModelEndpointSchema } from './schemas';
|
||||
import { specsConfigSchema, TSpecsConfig } from './models';
|
||||
import { fileConfigSchema } from './file-config';
|
||||
import { apiBaseUrl } from './api-endpoints';
|
||||
import { FileSources } from './types/files';
|
||||
import { MCPServersSchema } from './mcp';
|
||||
import { apiBaseUrl } from './api-endpoints';
|
||||
|
||||
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
|
||||
|
||||
|
|
@ -911,6 +911,7 @@ export enum KnownEndpoints {
|
|||
fireworks = 'fireworks',
|
||||
deepseek = 'deepseek',
|
||||
groq = 'groq',
|
||||
helicone = 'helicone',
|
||||
huggingface = 'huggingface',
|
||||
mistral = 'mistral',
|
||||
mlx = 'mlx',
|
||||
|
|
@ -926,6 +927,7 @@ export enum KnownEndpoints {
|
|||
|
||||
export enum FetchTokenConfig {
|
||||
openrouter = KnownEndpoints.openrouter,
|
||||
helicone = KnownEndpoints.helicone
|
||||
}
|
||||
|
||||
export const defaultEndpoints: EModelEndpoint[] = [
|
||||
|
|
@ -958,6 +960,7 @@ export const alternateName = {
|
|||
[KnownEndpoints.deepseek]: 'DeepSeek',
|
||||
[KnownEndpoints.xai]: 'xAI',
|
||||
[KnownEndpoints.vercel]: 'Vercel',
|
||||
[KnownEndpoints.helicone]: 'Helicone',
|
||||
};
|
||||
|
||||
const sharedOpenAIModels = [
|
||||
|
|
@ -1455,6 +1458,10 @@ export enum ErrorTypes {
|
|||
* Generic Authentication failure
|
||||
*/
|
||||
AUTH_FAILED = 'auth_failed',
|
||||
/**
|
||||
* Model refused to respond (content policy violation)
|
||||
*/
|
||||
REFUSAL = 'refusal',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1615,6 +1622,10 @@ export enum Constants {
|
|||
* This helps inform the UI if the mcp server was previously added.
|
||||
* */
|
||||
mcp_server = 'sys__server__sys',
|
||||
/**
|
||||
* Handoff Tool Name Prefix
|
||||
*/
|
||||
LC_TRANSFER_TO_ = 'lc_transfer_to_',
|
||||
/** Placeholder Agent ID for Ephemeral Agents */
|
||||
EPHEMERAL_AGENT_ID = 'ephemeral',
|
||||
}
|
||||
|
|
@ -1733,3 +1744,24 @@ export const specialVariables = {
|
|||
};
|
||||
|
||||
export type TSpecialVarLabel = `com_ui_special_var_${keyof typeof specialVariables}`;
|
||||
|
||||
/**
|
||||
* Retrieves a specific field from the endpoints configuration for a given endpoint key.
|
||||
* Does not infer or default any endpoint type when absent.
|
||||
*/
|
||||
export function getEndpointField<
|
||||
K extends TConfig[keyof TConfig] extends never ? never : keyof TConfig,
|
||||
>(
|
||||
endpointsConfig: TEndpointsConfig | undefined | null,
|
||||
endpoint: EModelEndpoint | string | null | undefined,
|
||||
property: K,
|
||||
): TConfig[K] | undefined {
|
||||
if (!endpointsConfig || endpoint === null || endpoint === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const config = endpointsConfig[endpoint];
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return config[property];
|
||||
}
|
||||
|
|
|
|||
1097
packages/data-provider/src/file-config.spec.ts
Normal file
1097
packages/data-provider/src/file-config.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { EModelEndpoint } from './schemas';
|
||||
import type { EndpointFileConfig, FileConfig } from './types/files';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isDocumentSupportedProvider } from './schemas';
|
||||
import { normalizeEndpointName } from './utils';
|
||||
|
||||
export const supportsFiles = {
|
||||
[EModelEndpoint.openAI]: true,
|
||||
|
|
@ -317,6 +318,8 @@ export const fileConfigSchema = z.object({
|
|||
.optional(),
|
||||
});
|
||||
|
||||
export type TFileConfig = z.infer<typeof fileConfigSchema>;
|
||||
|
||||
/** Helper function to safely convert string patterns to RegExp objects */
|
||||
export const convertStringsToRegex = (patterns: string[]): RegExp[] =>
|
||||
patterns.reduce((acc: RegExp[], pattern) => {
|
||||
|
|
@ -329,9 +332,146 @@ export const convertStringsToRegex = (patterns: string[]): RegExp[] =>
|
|||
return acc;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Gets the appropriate endpoint file configuration with standardized lookup logic.
|
||||
*
|
||||
* @param params - Object containing fileConfig, endpoint, and optional conversationEndpoint
|
||||
* @param params.fileConfig - The merged file configuration
|
||||
* @param params.endpoint - The endpoint name to look up
|
||||
* @param params.conversationEndpoint - Optional conversation endpoint for additional context
|
||||
* @returns The endpoint file configuration or undefined
|
||||
*/
|
||||
/**
|
||||
* Merges an endpoint config with the default config to ensure all fields are populated.
|
||||
* For document-supported providers, uses the comprehensive MIME type list (includes videos/audio).
|
||||
*/
|
||||
function mergeWithDefault(
|
||||
endpointConfig: EndpointFileConfig,
|
||||
defaultConfig: EndpointFileConfig,
|
||||
endpoint?: string | null,
|
||||
): EndpointFileConfig {
|
||||
/** Use comprehensive MIME types for document-supported providers */
|
||||
const defaultMimeTypes = isDocumentSupportedProvider(endpoint)
|
||||
? supportedMimeTypes
|
||||
: defaultConfig.supportedMimeTypes;
|
||||
|
||||
return {
|
||||
disabled: endpointConfig.disabled ?? defaultConfig.disabled,
|
||||
fileLimit: endpointConfig.fileLimit ?? defaultConfig.fileLimit,
|
||||
fileSizeLimit: endpointConfig.fileSizeLimit ?? defaultConfig.fileSizeLimit,
|
||||
totalSizeLimit: endpointConfig.totalSizeLimit ?? defaultConfig.totalSizeLimit,
|
||||
supportedMimeTypes: endpointConfig.supportedMimeTypes ?? defaultMimeTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEndpointFileConfig(params: {
|
||||
fileConfig?: FileConfig | null;
|
||||
endpoint?: string | null;
|
||||
endpointType?: string | null;
|
||||
}): EndpointFileConfig {
|
||||
const { fileConfig: mergedFileConfig, endpoint, endpointType } = params;
|
||||
|
||||
if (!mergedFileConfig?.endpoints) {
|
||||
return fileConfig.endpoints.default;
|
||||
}
|
||||
|
||||
/** Compute an effective default by merging user-configured default over the base default */
|
||||
const baseDefaultConfig = fileConfig.endpoints.default;
|
||||
const userDefaultConfig = mergedFileConfig.endpoints.default;
|
||||
const defaultConfig = userDefaultConfig
|
||||
? mergeWithDefault(userDefaultConfig, baseDefaultConfig, 'default')
|
||||
: baseDefaultConfig;
|
||||
|
||||
const normalizedEndpoint = normalizeEndpointName(endpoint ?? '');
|
||||
const standardEndpoints = new Set([
|
||||
'default',
|
||||
EModelEndpoint.agents,
|
||||
EModelEndpoint.assistants,
|
||||
EModelEndpoint.azureAssistants,
|
||||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
EModelEndpoint.anthropic,
|
||||
EModelEndpoint.google,
|
||||
EModelEndpoint.bedrock,
|
||||
]);
|
||||
|
||||
const normalizedEndpointType = normalizeEndpointName(endpointType ?? '');
|
||||
const isCustomEndpoint =
|
||||
endpointType === EModelEndpoint.custom ||
|
||||
(!standardEndpoints.has(normalizedEndpointType) &&
|
||||
normalizedEndpoint &&
|
||||
!standardEndpoints.has(normalizedEndpoint));
|
||||
|
||||
if (isCustomEndpoint) {
|
||||
/** 1. Check direct endpoint lookup (could be normalized or not) */
|
||||
if (endpoint && mergedFileConfig.endpoints[endpoint]) {
|
||||
return mergeWithDefault(mergedFileConfig.endpoints[endpoint], defaultConfig, endpoint);
|
||||
}
|
||||
/** 2. Check normalized endpoint lookup (skip standard endpoint keys) */
|
||||
for (const key in mergedFileConfig.endpoints) {
|
||||
if (!standardEndpoints.has(key) && normalizeEndpointName(key) === normalizedEndpoint) {
|
||||
return mergeWithDefault(mergedFileConfig.endpoints[key], defaultConfig, key);
|
||||
}
|
||||
}
|
||||
/** 3. Fallback to generic 'custom' config if any */
|
||||
if (mergedFileConfig.endpoints[EModelEndpoint.custom]) {
|
||||
return mergeWithDefault(
|
||||
mergedFileConfig.endpoints[EModelEndpoint.custom],
|
||||
defaultConfig,
|
||||
endpoint,
|
||||
);
|
||||
}
|
||||
/** 4. Fallback to 'agents' (all custom endpoints are non-assistants) */
|
||||
if (mergedFileConfig.endpoints[EModelEndpoint.agents]) {
|
||||
return mergeWithDefault(
|
||||
mergedFileConfig.endpoints[EModelEndpoint.agents],
|
||||
defaultConfig,
|
||||
endpoint,
|
||||
);
|
||||
}
|
||||
/** 5. Fallback to default */
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
/** Check endpointType first (most reliable for standard endpoints) */
|
||||
if (endpointType && mergedFileConfig.endpoints[endpointType]) {
|
||||
return mergeWithDefault(mergedFileConfig.endpoints[endpointType], defaultConfig, endpointType);
|
||||
}
|
||||
|
||||
/** Check direct endpoint lookup */
|
||||
if (endpoint && mergedFileConfig.endpoints[endpoint]) {
|
||||
return mergeWithDefault(mergedFileConfig.endpoints[endpoint], defaultConfig, endpoint);
|
||||
}
|
||||
|
||||
/** Check normalized endpoint */
|
||||
if (normalizedEndpoint && mergedFileConfig.endpoints[normalizedEndpoint]) {
|
||||
return mergeWithDefault(
|
||||
mergedFileConfig.endpoints[normalizedEndpoint],
|
||||
defaultConfig,
|
||||
normalizedEndpoint,
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback to agents if endpoint is explicitly agents */
|
||||
const isAgents = isAgentsEndpoint(normalizedEndpointType || normalizedEndpoint);
|
||||
if (isAgents && mergedFileConfig.endpoints[EModelEndpoint.agents]) {
|
||||
return mergeWithDefault(
|
||||
mergedFileConfig.endpoints[EModelEndpoint.agents],
|
||||
defaultConfig,
|
||||
EModelEndpoint.agents,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return default config */
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | undefined): FileConfig {
|
||||
const mergedConfig: FileConfig = {
|
||||
...fileConfig,
|
||||
endpoints: {
|
||||
...fileConfig.endpoints,
|
||||
},
|
||||
ocr: {
|
||||
...fileConfig.ocr,
|
||||
supportedMimeTypes: fileConfig.ocr?.supportedMimeTypes || [],
|
||||
|
|
@ -396,8 +536,11 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
|
|||
for (const key in dynamic.endpoints) {
|
||||
const dynamicEndpoint = (dynamic.endpoints as Record<string, EndpointFileConfig>)[key];
|
||||
|
||||
/** Deep copy the base endpoint config if it exists to prevent mutation */
|
||||
if (!mergedConfig.endpoints[key]) {
|
||||
mergedConfig.endpoints[key] = {};
|
||||
} else {
|
||||
mergedConfig.endpoints[key] = { ...mergedConfig.endpoints[key] };
|
||||
}
|
||||
|
||||
const mergedEndpoint = mergedConfig.endpoints[key];
|
||||
|
|
@ -426,6 +569,10 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
|
|||
}
|
||||
});
|
||||
|
||||
if (dynamicEndpoint.disabled !== undefined) {
|
||||
mergedEndpoint.disabled = dynamicEndpoint.disabled;
|
||||
}
|
||||
|
||||
if (dynamicEndpoint.supportedMimeTypes) {
|
||||
mergedEndpoint.supportedMimeTypes = convertStringsToRegex(
|
||||
dynamicEndpoint.supportedMimeTypes as unknown as string[],
|
||||
|
|
|
|||
|
|
@ -492,6 +492,19 @@ const bedrock: Record<string, SettingDefinition> = {
|
|||
default: 0.999,
|
||||
range: { min: 0, max: 1, step: 0.01 },
|
||||
}),
|
||||
promptCache: {
|
||||
key: 'promptCache',
|
||||
label: 'com_endpoint_prompt_cache',
|
||||
labelCode: true,
|
||||
type: 'boolean',
|
||||
description: 'com_endpoint_anthropic_prompt_cache',
|
||||
descriptionCode: true,
|
||||
default: true,
|
||||
component: 'switch',
|
||||
optionType: 'conversation',
|
||||
showDefault: false,
|
||||
columnSpan: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const mistral: Record<string, SettingDefinition> = {
|
||||
|
|
@ -752,6 +765,7 @@ const bedrockAnthropic: SettingsConfiguration = [
|
|||
baseDefinitions.stop,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
bedrock.promptCache,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
librechat.fileTokenLimit,
|
||||
|
|
@ -789,6 +803,7 @@ const bedrockGeneral: SettingsConfiguration = [
|
|||
meta.topP,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
bedrock.promptCache,
|
||||
librechat.fileTokenLimit,
|
||||
];
|
||||
|
||||
|
|
@ -807,6 +822,7 @@ const bedrockAnthropicCol2: SettingsConfiguration = [
|
|||
bedrock.topK,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
bedrock.promptCache,
|
||||
anthropic.thinking,
|
||||
anthropic.thinkingBudget,
|
||||
librechat.fileTokenLimit,
|
||||
|
|
@ -856,6 +872,7 @@ const bedrockGeneralCol2: SettingsConfiguration = [
|
|||
meta.topP,
|
||||
librechat.resendFiles,
|
||||
bedrock.region,
|
||||
bedrock.promptCache,
|
||||
librechat.fileTokenLimit,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export enum Providers {
|
|||
GOOGLE = 'google',
|
||||
VERTEXAI = 'vertexai',
|
||||
BEDROCK = 'bedrock',
|
||||
BEDROCK_LEGACY = 'bedrock_legacy',
|
||||
MISTRALAI = 'mistralai',
|
||||
MISTRAL = 'mistral',
|
||||
OLLAMA = 'ollama',
|
||||
|
|
@ -231,6 +230,7 @@ export const defaultAgentFormValues = {
|
|||
tools: [],
|
||||
provider: {},
|
||||
projectIds: [],
|
||||
edges: [],
|
||||
artifacts: '',
|
||||
/** @deprecated Use ACL permissions instead */
|
||||
isCollaborative: false,
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ export type TConfig = {
|
|||
capabilities?: string[];
|
||||
customParams?: {
|
||||
defaultParamsEndpoint?: string;
|
||||
paramDefinitions?: SettingDefinition[];
|
||||
paramDefinitions?: Partial<SettingDefinition>[];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -355,3 +355,45 @@ export type AgentToolType = {
|
|||
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
|
||||
|
||||
export type ToolMetadata = TPlugin;
|
||||
|
||||
export interface BaseMessage {
|
||||
content: string;
|
||||
role?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BaseGraphState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type GraphEdge = {
|
||||
/** Agent ID, use a list for multiple sources */
|
||||
from: string | string[];
|
||||
/** Agent ID, use a list for multiple destinations */
|
||||
to: string | string[];
|
||||
description?: string;
|
||||
/** Can return boolean or specific destination(s) */
|
||||
condition?: (state: BaseGraphState) => boolean | string | string[];
|
||||
/** 'handoff' creates tools for dynamic routing, 'direct' creates direct edges, which also allow parallel execution */
|
||||
edgeType?: 'handoff' | 'direct';
|
||||
/**
|
||||
* For direct edges: Optional prompt to add when transitioning through this edge.
|
||||
* String prompts can include variables like {results} which will be replaced with
|
||||
* messages from startIndex onwards. When {results} is used, excludeResults defaults to true.
|
||||
*
|
||||
* For handoff edges: Description for the input parameter that the handoff tool accepts,
|
||||
* allowing the supervisor to pass specific instructions/context to the transferred agent.
|
||||
*/
|
||||
prompt?: string | ((messages: BaseMessage[], runStartIndex: number) => string | undefined);
|
||||
/**
|
||||
* When true, excludes messages from startIndex when adding prompt.
|
||||
* Automatically set to true when {results} variable is used in prompt.
|
||||
*/
|
||||
excludeResults?: boolean;
|
||||
/**
|
||||
* For handoff edges: Customizes the parameter name for the handoff input.
|
||||
* Defaults to "instructions" if not specified.
|
||||
* Only applies when prompt is provided for handoff edges.
|
||||
*/
|
||||
promptKey?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { AssistantsEndpoint, AgentProvider } from 'src/schemas';
|
||||
import type { Agents, GraphEdge } from './agents';
|
||||
import type { ContentTypes } from './runs';
|
||||
import type { Agents } from './agents';
|
||||
import type { TFile } from './files';
|
||||
import { ArtifactModes } from 'src/artifacts';
|
||||
|
||||
|
|
@ -229,7 +229,9 @@ export type Agent = {
|
|||
/** @deprecated Use ACL permissions instead */
|
||||
isCollaborative?: boolean;
|
||||
tool_resources?: AgentToolResources;
|
||||
/** @deprecated Use edges instead */
|
||||
agent_ids?: string[];
|
||||
edges?: GraphEdge[];
|
||||
end_after_tools?: boolean;
|
||||
hide_sequential_outputs?: boolean;
|
||||
artifacts?: ArtifactModes;
|
||||
|
|
@ -255,6 +257,7 @@ export type AgentCreateParams = {
|
|||
} & Pick<
|
||||
Agent,
|
||||
| 'agent_ids'
|
||||
| 'edges'
|
||||
| 'end_after_tools'
|
||||
| 'hide_sequential_outputs'
|
||||
| 'artifacts'
|
||||
|
|
@ -280,6 +283,7 @@ export type AgentUpdateParams = {
|
|||
} & Pick<
|
||||
Agent,
|
||||
| 'agent_ids'
|
||||
| 'edges'
|
||||
| 'end_after_tools'
|
||||
| 'hide_sequential_outputs'
|
||||
| 'artifacts'
|
||||
|
|
|
|||
|
|
@ -52,3 +52,11 @@ export function extractEnvVariable(value: string) {
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the endpoint name to system-expected value.
|
||||
* @param name
|
||||
*/
|
||||
export function normalizeEndpointName(name = ''): string {
|
||||
return name.toLowerCase() === 'ollama' ? 'ollama' : name;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue