Merge branch 'main' into feature/entra-id-azure-integration

This commit is contained in:
victorbjor 2025-11-14 10:39:01 +01:00 committed by GitHub
commit af661b1df2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
293 changed files with 20207 additions and 13884 deletions

View file

@ -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');
});
});
});

View file

@ -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);

View file

@ -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}`;

View file

@ -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) || {}),

View file

@ -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];
}

File diff suppressed because it is too large Load diff

View file

@ -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[],

View file

@ -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,
];

View file

@ -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,

View file

@ -347,7 +347,7 @@ export type TConfig = {
capabilities?: string[];
customParams?: {
defaultParamsEndpoint?: string;
paramDefinitions?: SettingDefinition[];
paramDefinitions?: Partial<SettingDefinition>[];
};
};

View file

@ -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;
};

View file

@ -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'

View file

@ -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;
}