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

This commit is contained in:
victorbjor 2025-12-15 15:40:47 +01:00 committed by GitHub
commit a7cf1ae27b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 25653 additions and 3303 deletions

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.8.020",
"version": "0.8.200",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",
@ -30,7 +30,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/danny-avila/LibreChat.git"
"url": "https://github.com/danny-avila/LibreChat"
},
"author": "",
"license": "ISC",
@ -50,7 +50,7 @@
"@babel/preset-typescript": "^7.21.0",
"@langchain/core": "^0.3.62",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.5",
@ -63,7 +63,7 @@
"jest": "^30.2.0",
"jest-junit": "^16.0.0",
"openapi-types": "^12.1.3",
"rimraf": "^5.0.1",
"rimraf": "^6.1.2",
"rollup": "^4.22.4",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-typescript2": "^0.35.0",

View file

@ -1539,6 +1539,60 @@ describe('SSRF Protection', () => {
'http://169.254.169.254',
);
});
it('handles IPv6 URLs with brackets correctly', () => {
expect(extractDomainFromUrl('http://[::1]/')).toBe('http://[::1]');
expect(extractDomainFromUrl('http://[::1]:8080')).toBe('http://[::1]');
expect(extractDomainFromUrl('https://[2001:db8::1]/api')).toBe('https://[2001:db8::1]');
expect(extractDomainFromUrl('http://[fe80::1]/path')).toBe('http://[fe80::1]');
});
it('handles complex IPv6 addresses', () => {
expect(extractDomainFromUrl('http://[2001:db8:85a3::8a2e:370:7334]/api')).toBe(
'http://[2001:db8:85a3::8a2e:370:7334]',
);
// Node.js normalizes IPv4-mapped IPv6 to hex form
expect(extractDomainFromUrl('https://[::ffff:192.168.1.1]:8080')).toBe(
'https://[::ffff:c0a8:101]',
);
});
it('handles URLs with authentication credentials', () => {
expect(extractDomainFromUrl('https://user:pass@example.com/api')).toBe('https://example.com');
expect(extractDomainFromUrl('http://admin@192.168.1.1:8080')).toBe('http://192.168.1.1');
});
it('handles URLs with special characters in path', () => {
expect(extractDomainFromUrl('https://example.com/path%20with%20spaces')).toBe(
'https://example.com',
);
expect(extractDomainFromUrl('https://example.com/path#fragment')).toBe('https://example.com');
expect(extractDomainFromUrl('https://example.com/?query=value&other=123')).toBe(
'https://example.com',
);
});
it('handles localhost variations', () => {
expect(extractDomainFromUrl('http://localhost/')).toBe('http://localhost');
expect(extractDomainFromUrl('https://localhost:3000')).toBe('https://localhost');
expect(extractDomainFromUrl('http://localhost.localdomain')).toBe(
'http://localhost.localdomain',
);
});
it('handles internationalized domain names', () => {
expect(extractDomainFromUrl('https://xn--e1afmkfd.xn--p1ai/api')).toBe(
'https://xn--e1afmkfd.xn--p1ai',
);
// Node.js URL parser converts IDN to punycode
expect(extractDomainFromUrl('https://münchen.de')).toBe('https://xn--mnchen-3ya.de');
});
it('throws error for non-HTTP/HTTPS protocols in extractDomainFromUrl', () => {
expect(() => extractDomainFromUrl('ftp://example.com')).not.toThrow();
expect(extractDomainFromUrl('ftp://example.com')).toBe('ftp://example.com');
// Note: The function doesn't validate protocol, just extracts domain
});
});
describe('validateAndParseOpenAPISpec - SSRF Prevention', () => {
@ -1738,7 +1792,7 @@ describe('SSRF Protection', () => {
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('example.com');
expect(result.message).toContain('https://malicious.com');
expect(result.message).toContain('malicious.com');
});
it('detects SSRF attempt with internal IP', () => {
@ -1837,5 +1891,567 @@ describe('SSRF Protection', () => {
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://api.openai.com');
});
// Tests for IP address validation (fix for the reported issue)
it('validates matching IP addresses when client provides just IP (no protocol)', () => {
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.25:7894/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
});
it('validates matching localhost IP when client provides just IP', () => {
const result = validateActionDomain('127.0.0.1', 'http://127.0.0.1:8080/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://127.0.0.1');
expect(result.normalizedClientDomain).toBe('http://127.0.0.1');
});
it('validates matching private network IP when client provides just IP', () => {
const result = validateActionDomain('192.168.1.100', 'https://192.168.1.100:443/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://192.168.1.100');
expect(result.normalizedClientDomain).toBe('https://192.168.1.100');
});
it('validates matching IP when client provides full URL with IP', () => {
const result = validateActionDomain('http://10.225.26.25', 'http://10.225.26.25:7894');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
});
it('rejects mismatched IP addresses', () => {
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.26:7894/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('10.225.26.25');
expect(result.message).toContain('10.225.26.26');
});
it('rejects IP when domain expected', () => {
const result = validateActionDomain('example.com', 'http://192.168.1.1/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
});
it('rejects domain when IP expected', () => {
const result = validateActionDomain('192.168.1.1', 'http://malicious.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('192.168.1.1');
expect(result.message).toContain('malicious.com');
});
it('handles IPv6 addresses when client provides just IP', () => {
const result = validateActionDomain('[::1]', 'http://[::1]:8080/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://[::1]');
expect(result.normalizedClientDomain).toBe('http://[::1]');
});
// Additional IP-based SSRF tests for comprehensive security coverage
it('prevents using whitelisted IP to access different IP', () => {
const result = validateActionDomain('192.168.1.100', 'http://192.168.1.101/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.message).toContain('192.168.1.100');
expect(result.message).toContain('192.168.1.101');
});
it('prevents using external IP to access localhost', () => {
const result = validateActionDomain('8.8.8.8', 'http://127.0.0.1/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents using localhost to access private network', () => {
const result = validateActionDomain('127.0.0.1', 'http://192.168.1.1/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('detects SSRF with 0.0.0.0 binding address', () => {
const result = validateActionDomain('example.com', 'http://0.0.0.0:8080');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
});
it('validates matching 0.0.0.0 when legitimately used', () => {
const result = validateActionDomain('0.0.0.0', 'http://0.0.0.0:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
});
it('prevents link-local address SSRF (169.254.x.x)', () => {
const result = validateActionDomain('api.example.com', 'http://169.254.10.10/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
});
it('validates matching link-local when explicitly allowed', () => {
const result = validateActionDomain('169.254.10.10', 'http://169.254.10.10/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
});
it('prevents Docker internal network access via SSRF', () => {
const result = validateActionDomain('public-api.com', 'http://172.17.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://172.17.0.1');
});
it('prevents Kubernetes service network SSRF', () => {
const result = validateActionDomain('api.company.com', 'http://10.96.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('detects protocol mismatch for IP addresses', () => {
const result = validateActionDomain('https://192.168.1.1', 'http://192.168.1.1/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
expect(result.normalizedClientDomain).toBe('https://192.168.1.1');
});
it('prevents IPv6 localhost bypass attempts', () => {
const result = validateActionDomain('example.com', 'http://[::1]/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
expect(result.normalizedSpecDomain).toBe('http://[::1]');
});
it('prevents IPv6 link-local SSRF (fe80::)', () => {
const result = validateActionDomain('api.example.com', 'http://[fe80::1]/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates matching IPv6 link-local when explicitly allowed', () => {
const result = validateActionDomain('[fe80::1]', 'http://[fe80::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('http://[fe80::1]');
});
it('prevents multicast address SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://224.0.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents broadcast address SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://255.255.255.255/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Cloud Provider Metadata Service Tests
it('prevents AWS IMDSv1 metadata access', () => {
const result = validateActionDomain(
'trusted-api.com',
'http://169.254.169.254/latest/meta-data/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents AWS IMDSv2 token endpoint access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/latest/api/token',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents GCP metadata access via metadata.google.internal', () => {
const result = validateActionDomain(
'api.example.com',
'http://metadata.google.internal/computeMetadata/v1/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Azure IMDS access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/metadata/instance?api-version=2021-02-01',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents DigitalOcean metadata access', () => {
const result = validateActionDomain('api.example.com', 'http://169.254.169.254/metadata/v1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Oracle Cloud metadata access', () => {
const result = validateActionDomain(
'api.example.com',
'http://169.254.169.254/opc/v1/instance/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Alibaba Cloud metadata access', () => {
const result = validateActionDomain(
'api.example.com',
'http://100.100.100.200/latest/meta-data/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Container & Orchestration Internal Services
it('prevents Kubernetes API server access', () => {
const result = validateActionDomain(
'api.example.com',
'https://kubernetes.default.svc.cluster.local/',
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Docker host access from container', () => {
const result = validateActionDomain('api.example.com', 'http://host.docker.internal/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Rancher metadata service access', () => {
const result = validateActionDomain('api.example.com', 'http://rancher-metadata/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Common Internal Service Ports
it('prevents Redis default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:6379/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents Elasticsearch default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:9200/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents MongoDB default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:27017/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents PostgreSQL default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:5432/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents MySQL default port access', () => {
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:3306/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
// Alternative localhost representations
it('prevents localhost.localdomain SSRF', () => {
const result = validateActionDomain('api.example.com', 'http://localhost.localdomain/admin');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates matching localhost.localdomain when explicitly allowed', () => {
const result = validateActionDomain(
'localhost.localdomain',
'https://localhost.localdomain/api',
);
expect(result.isValid).toBe(true);
});
// Edge cases with special IPs
it('prevents class E reserved IP range access', () => {
const result = validateActionDomain('api.example.com', 'http://240.0.0.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('prevents TEST-NET-1 range access when not matching', () => {
const result = validateActionDomain('api.example.com', 'http://192.0.2.1/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Domain mismatch');
});
it('validates TEST-NET-1 when explicitly matching', () => {
const result = validateActionDomain('192.0.2.1', 'http://192.0.2.1/api');
expect(result.isValid).toBe(true);
});
// Mixed protocol and IP scenarios (unsupported protocols)
it('rejects unsupported WebSocket protocol', () => {
const result = validateActionDomain('api.example.com', 'ws://api.example.com:8080/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('ws:');
});
it('rejects unsupported FTP protocol', () => {
const result = validateActionDomain('ftp.example.com', 'ftp://ftp.example.com/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('ftp:');
});
it('rejects WSS (secure WebSocket) protocol', () => {
const result = validateActionDomain('api.example.com', 'wss://api.example.com:8080/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('wss:');
});
it('rejects file:// protocol for local file access', () => {
const result = validateActionDomain('localhost', 'file:///etc/passwd');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('file:');
});
it('rejects gopher:// protocol', () => {
const result = validateActionDomain('example.com', 'gopher://example.com/');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('gopher:');
});
it('rejects data: URL protocol', () => {
const result = validateActionDomain('example.com', 'data:text/plain,Hello');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('data:');
});
// Tests for Copilot second review catches
it('rejects unsupported protocol in client domain', () => {
const result = validateActionDomain('ftp://evil.com', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('rejects WebSocket protocol in client domain', () => {
const result = validateActionDomain('ws://evil.com', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('rejects file protocol in client domain', () => {
const result = validateActionDomain('file:///etc/passwd', 'https://trusted.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
expect(result.message).toContain('client domain');
});
it('handles IPv6 address without brackets from client', () => {
const result = validateActionDomain('2001:db8::1', 'http://[2001:db8::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
});
it('handles IPv6 address with brackets from client', () => {
const result = validateActionDomain('[2001:db8::1]', 'http://[2001:db8::1]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
});
// Ensure legitimate internal use cases still work
it('allows legitimate internal API with matching IP', () => {
const result = validateActionDomain('10.0.0.5', 'http://10.0.0.5:8080/api');
expect(result.isValid).toBe(true);
});
it('allows legitimate Docker internal when explicitly specified', () => {
const result = validateActionDomain(
'host.docker.internal',
'https://host.docker.internal:3000/api',
);
expect(result.isValid).toBe(true);
});
it('allows legitimate Kubernetes service when explicitly specified', () => {
const result = validateActionDomain(
'myservice.default.svc.cluster.local',
'https://myservice.default.svc.cluster.local/api',
);
expect(result.isValid).toBe(true);
});
// Additional coverage tests for error paths and edge cases
it('handles malformed URL in client domain gracefully', () => {
const result = validateActionDomain('http://[invalid', 'https://example.com/api');
expect(result.isValid).toBe(false);
});
it('handles error in spec URL parsing', () => {
const result = validateActionDomain('example.com', 'not-a-valid-url');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Failed to validate domain');
});
it('validates when client provides HTTP and spec uses HTTP', () => {
const result = validateActionDomain('http://example.com', 'http://example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://example.com');
expect(result.normalizedSpecDomain).toBe('http://example.com');
});
it('validates when client provides HTTPS and spec uses HTTPS', () => {
const result = validateActionDomain('https://example.com', 'https://example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://example.com');
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles IPv4 with explicit protocol from client', () => {
const result = validateActionDomain('http://192.168.1.1', 'http://192.168.1.1:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://192.168.1.1');
});
it('handles localhost as a domain', () => {
const result = validateActionDomain('localhost', 'https://localhost:3000/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://localhost');
expect(result.normalizedSpecDomain).toBe('https://localhost');
});
it('rejects javascript: protocol in client domain', () => {
const result = validateActionDomain('javascript:alert(1)', 'https://example.com/api');
expect(result.isValid).toBe(false);
// javascript: doesn't have :// so it's treated as a hostname mismatch
expect(result.message).toContain('Domain mismatch');
});
it('handles empty string as client domain', () => {
const result = validateActionDomain('', 'https://example.com/api');
expect(result.isValid).toBe(false);
});
it('handles spec URL without path', () => {
const result = validateActionDomain('example.com', 'https://example.com');
expect(result.isValid).toBe(true);
});
it('handles spec URL with query parameters', () => {
const result = validateActionDomain(
'api.example.com',
'https://api.example.com/v1?key=value',
);
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://api.example.com');
});
it('handles subdomain matching correctly', () => {
const result = validateActionDomain(
'api.v2.example.com',
'https://api.v2.example.com/endpoint',
);
expect(result.isValid).toBe(true);
});
it('rejects SSH protocol in client domain', () => {
const result = validateActionDomain('ssh://git@github.com', 'https://github.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
});
it('handles punycode/internationalized domains', () => {
const result = validateActionDomain(
'xn--e1afmkfd.xn--p1ai',
'https://xn--e1afmkfd.xn--p1ai/api',
);
expect(result.isValid).toBe(true);
});
it('validates IPv6 localhost variations', () => {
const result = validateActionDomain('::1', 'http://[::1]:8080');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[::1]');
});
it('handles spec URL with username in URL', () => {
const result = validateActionDomain('example.com', 'https://user@example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles spec URL with username and password', () => {
const result = validateActionDomain('example.com', 'https://user:pass@example.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles complex IPv6 addresses', () => {
const result = validateActionDomain(
'2001:db8:85a3::8a2e:370:7334',
'http://[2001:db8:85a3::8a2e:370:7334]/api',
);
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[2001:db8:85a3::8a2e:370:7334]');
});
it('handles IPv4-mapped IPv6 addresses', () => {
// Node.js normalizes IPv4-mapped IPv6 differently in URL parsing
const result = validateActionDomain('::ffff:c0a8:101', 'http://[::ffff:c0a8:101]/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('http://[::ffff:c0a8:101]');
});
it('rejects telnet protocol in client domain', () => {
const result = validateActionDomain('telnet://example.com', 'https://example.com/api');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid protocol');
});
it('handles client domain with port and no protocol', () => {
const result = validateActionDomain('example.com:443', 'https://example.com:443/api');
// Port is included in hostname comparison, causing mismatch
expect(result.isValid).toBe(false);
expect(result.normalizedClientDomain).toBe('https://example.com:443');
expect(result.normalizedSpecDomain).toBe('https://example.com');
});
it('handles TLD-only domains', () => {
const result = validateActionDomain('localhost', 'http://localhost/api');
expect(result.isValid).toBe(false); // HTTP vs HTTPS mismatch
expect(result.normalizedClientDomain).toBe('https://localhost');
expect(result.normalizedSpecDomain).toBe('http://localhost');
});
it('validates when both URLs have ports', () => {
const result = validateActionDomain(
'https://api.example.com:8443',
'https://api.example.com:8443/v1',
);
expect(result.isValid).toBe(true);
});
it('handles client domain that looks like URL but missing protocol separator', () => {
const result = validateActionDomain('httpexample.com', 'https://httpexample.com/api');
expect(result.isValid).toBe(true);
expect(result.normalizedClientDomain).toBe('https://httpexample.com');
});
});
});

View file

@ -1,6 +1,7 @@
import { replaceSpecialVars } from '../src/parsers';
import { replaceSpecialVars, parseCompactConvo } from '../src/parsers';
import { specialVariables } from '../src/config';
import type { TUser } from '../src/types';
import { EModelEndpoint } from '../src/schemas';
import type { TUser, TConversation } from '../src/types';
// Mock dayjs module with consistent date/time values regardless of environment
jest.mock('dayjs', () => {
@ -123,3 +124,138 @@ describe('replaceSpecialVars', () => {
expect(result).toContain('Test User'); // current_user
});
});
describe('parseCompactConvo', () => {
describe('iconURL security sanitization', () => {
test('should strip iconURL from OpenAI endpoint conversation input', () => {
const maliciousIconURL = 'https://evil-tracker.example.com/pixel.png?user=victim';
const conversation: Partial<TConversation> = {
model: 'gpt-4',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.openAI,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
});
test('should strip iconURL from agents endpoint conversation input', () => {
const maliciousIconURL = 'https://evil-tracker.example.com/pixel.png';
const conversation: Partial<TConversation> = {
agent_id: 'agent_123',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.agents,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.agents,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.agent_id).toBe('agent_123');
});
test('should strip iconURL from anthropic endpoint conversation input', () => {
const maliciousIconURL = 'https://tracker.malicious.com/beacon.gif';
const conversation: Partial<TConversation> = {
model: 'claude-3-opus',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.anthropic,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.anthropic,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('claude-3-opus');
});
test('should strip iconURL from google endpoint conversation input', () => {
const maliciousIconURL = 'https://tracking.example.com/spy.png';
const conversation: Partial<TConversation> = {
model: 'gemini-pro',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.google,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.google,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gemini-pro');
});
test('should strip iconURL from assistants endpoint conversation input', () => {
const maliciousIconURL = 'https://evil.com/track.png';
const conversation: Partial<TConversation> = {
assistant_id: 'asst_123',
iconURL: maliciousIconURL,
endpoint: EModelEndpoint.assistants,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.assistants,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.assistant_id).toBe('asst_123');
});
test('should preserve other conversation properties while stripping iconURL', () => {
const conversation: Partial<TConversation> = {
model: 'gpt-4',
iconURL: 'https://malicious.com/track.png',
endpoint: EModelEndpoint.openAI,
temperature: 0.7,
top_p: 0.9,
promptPrefix: 'You are a helpful assistant.',
maxContextTokens: 4000,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
expect(result?.temperature).toBe(0.7);
expect(result?.top_p).toBe(0.9);
expect(result?.promptPrefix).toBe('You are a helpful assistant.');
expect(result?.maxContextTokens).toBe(4000);
});
test('should handle conversation without iconURL (no error)', () => {
const conversation: Partial<TConversation> = {
model: 'gpt-4',
endpoint: EModelEndpoint.openAI,
};
const result = parseCompactConvo({
endpoint: EModelEndpoint.openAI,
conversation,
});
expect(result).not.toBeNull();
expect(result?.iconURL).toBeUndefined();
expect(result?.model).toBe('gpt-4');
});
});
});

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import _axios from 'axios';
import { URL } from 'url';
import _axios from 'axios';
import crypto from 'crypto';
import { load } from 'js-yaml';
import type { ActionMetadata, ActionMetadataRuntime } from './types/agents';
@ -567,16 +567,44 @@ export type ValidationResult = {
};
/**
* 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).
* Cross-platform IP validation (works in Node.js and browser).
* @param input - String to check if it's an IP address
* @returns 0 if not IP, 4 for IPv4, 6 for IPv6
*/
function isIP(input: string): number {
// IPv4 regex - matches 0.0.0.0 to 255.255.255.255
const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(input)) {
return 4;
}
// IPv6 regex - simplified but covers most cases
// Handles compressed (::), full, and mixed notations
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
if (ipv6Regex.test(input)) {
return 6;
}
return 0;
}
/**
* Extracts domain from URL (protocol + hostname).
* @param url - URL to extract from
* @returns Protocol and hostname (e.g., "https://example.com")
*/
export function extractDomainFromUrl(url: string): string {
try {
/** Parsed URL object */
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}`;
// Preserve brackets for IPv6 addresses using isIP
const ipVersion = isIP(parsedUrl.hostname);
const hostname = ipVersion === 6 ? `[${parsedUrl.hostname}]` : parsedUrl.hostname;
return `${parsedUrl.protocol}//${hostname}`;
} catch {
throw new Error(`Invalid URL format: ${url}`);
}
@ -590,45 +618,100 @@ export type DomainValidationResult = {
};
/**
* 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
* Validates client domain matches OpenAPI spec server URL domain (SSRF prevention).
* @param clientProvidedDomain - Domain from client (with/without protocol)
* @param specServerUrl - Server URL from OpenAPI spec
* @returns 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);
/** Parsed spec URL */
const specUrl = new URL(specServerUrl);
// 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
) {
if (specUrl.protocol !== 'http:' && specUrl.protocol !== 'https:') {
return {
isValid: false,
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${normalizedSpecDomain}'`,
message: `Invalid protocol: Only HTTP and HTTPS are allowed, got ${specUrl.protocol}`,
};
}
/** Spec hostname only */
const specHostname = specUrl.hostname;
/** Spec domain with protocol (handle IPv6 brackets) */
const specIpVersion = isIP(specHostname);
const normalizedSpecDomain =
specIpVersion === 6
? `${specUrl.protocol}//[${specHostname}]`
: `${specUrl.protocol}//${specHostname}`;
/** Extract hostname from client domain if it's a full URL */
let clientHostname = clientProvidedDomain;
let clientHasProtocol = false;
// Check for any protocol in the client domain
if (clientProvidedDomain.includes('://')) {
if (
!clientProvidedDomain.startsWith('http://') &&
!clientProvidedDomain.startsWith('https://')
) {
return {
isValid: false,
message: `Invalid protocol: Only HTTP and HTTPS are allowed in client domain`,
};
}
try {
const clientUrl = new URL(clientProvidedDomain);
clientHostname = clientUrl.hostname;
clientHasProtocol = true;
} catch {
// If parsing fails, treat as hostname
clientHasProtocol = false;
}
}
/** Normalize IPv6 addresses by removing brackets for comparison */
const normalizedClientHostname = clientHostname.replace(/^\[(.+)\]$/, '$1');
const normalizedSpecHostname = specHostname.replace(/^\[(.+)\]$/, '$1');
/** Check if hostname is valid IP using cross-platform isIP */
const isIPAddress = isIP(normalizedClientHostname) !== 0;
/** Normalized client domain */
let normalizedClientDomain: string;
if (clientHasProtocol) {
normalizedClientDomain = extractDomainFromUrl(clientProvidedDomain);
} else {
// IP addresses inherit protocol from spec, domains default to https
if (isIPAddress) {
// IPv6 addresses need brackets in URLs
const ipVersion = isIP(normalizedClientHostname);
const hostname =
ipVersion === 6 && !clientHostname.startsWith('[')
? `[${normalizedClientHostname}]`
: clientHostname;
normalizedClientDomain = `${specUrl.protocol}//${hostname}`;
} else {
normalizedClientDomain = `https://${clientHostname}`;
}
}
if (
normalizedSpecDomain === normalizedClientDomain ||
(!clientHasProtocol && isIPAddress && normalizedClientHostname === normalizedSpecHostname)
) {
return {
isValid: true,
normalizedSpecDomain,
normalizedClientDomain,
};
}
return {
isValid: true,
isValid: false,
message: `Domain mismatch: Client provided '${clientProvidedDomain}', but spec uses '${specHostname}'`,
normalizedSpecDomain,
normalizedClientDomain,
};

View file

@ -149,6 +149,10 @@ export const resetPassword = () => `${BASE_URL}/api/auth/resetPassword`;
export const verifyEmail = () => `${BASE_URL}/api/user/verify`;
// Auth page URLs (for client-side navigation and redirects)
export const loginPage = () => `${BASE_URL}/login`;
export const registerPage = () => `${BASE_URL}/register`;
export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;
export const plugins = () => `${BASE_URL}/api/plugins`;

View file

@ -1003,6 +1003,7 @@ const sharedAnthropicModels = [
'claude-haiku-4-5-20251001',
'claude-opus-4-1',
'claude-opus-4-1-20250805',
'claude-opus-4-5',
'claude-sonnet-4-20250514',
'claude-sonnet-4-0',
'claude-opus-4-20250514',
@ -1132,6 +1133,7 @@ export const supportsBalanceCheck = {
[EModelEndpoint.azureAssistants]: true,
[EModelEndpoint.azureOpenAI]: true,
[EModelEndpoint.bedrock]: true,
[EModelEndpoint.google]: true,
};
export const visionModels = [
@ -1584,7 +1586,7 @@ export enum TTSProviders {
/** Enum for app-wide constants */
export enum Constants {
/** Key for the app's version. */
VERSION = 'v0.8.1-rc1',
VERSION = 'v0.8.1',
/** Key for the Custom Config's version (librechat.yaml). */
CONFIG_VERSION = '1.3.1',
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */

View file

@ -200,6 +200,27 @@ export const codeTypeMapping: { [key: string]: string } = {
tsv: 'text/tab-separated-values',
};
/** Maps image extensions to MIME types for formats browsers may not recognize */
export const imageTypeMapping: { [key: string]: string } = {
heic: 'image/heic',
heif: 'image/heif',
};
/**
* Infers the MIME type from a file's extension when the browser doesn't recognize it
* @param fileName - The name of the file including extension
* @param currentType - The current MIME type reported by the browser (may be empty)
* @returns The inferred MIME type if browser didn't provide one, otherwise the original type
*/
export function inferMimeType(fileName: string, currentType: string): string {
if (currentType) {
return currentType;
}
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
return codeTypeMapping[extension] || imageTypeMapping[extension] || currentType;
}
export const retrievalMimeTypes = [
/^(text\/(x-c|x-c\+\+|x-h|html|x-java|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|vtt|xml))$/,
/^(application\/(json|pdf|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)))$/,

View file

@ -33,6 +33,7 @@ export * from './accessPermissions';
export * from './keys';
/* api call helpers */
export * from './headers-helpers';
export { loginPage, registerPage, apiBaseUrl } from './api-endpoints';
export { default as request } from './request';
export { dataService };
import * as dataService from './data-service';

View file

@ -56,6 +56,8 @@ const BaseOptionsSchema = z.object({
response_types_supported: z.array(z.string()).optional(),
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
code_challenge_methods_supported: z.array(z.string()).optional(),
/** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */
skip_code_challenge_check: z.boolean().optional(),
/** OAuth revocation endpoint (optional - can be auto-discovered) */
revocation_endpoint: z.string().url().optional(),
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */

View file

@ -326,7 +326,7 @@ export const parseCompactConvo = ({
possibleValues?: TPossibleValues;
// TODO: POC for default schema
// defaultSchema?: Partial<EndpointSchema>,
}) => {
}): Omit<s.TConversation, 'iconURL'> | null => {
if (!endpoint) {
throw new Error(`undefined endpoint: ${endpoint}`);
}
@ -343,7 +343,11 @@ export const parseCompactConvo = ({
throw new Error(`Unknown endpointType: ${endpointType}`);
}
const convo = schema.parse(conversation) as s.TConversation | null;
// Strip iconURL from input before parsing - it should only be derived server-side
// from model spec configuration, not accepted from client requests
const { iconURL: _clientIconURL, ...conversationWithoutIconURL } = conversation;
const convo = schema.parse(conversationWithoutIconURL) as s.TConversation | null;
// const { models, secondaryModels } = possibleValues ?? {};
const { models } = possibleValues ?? {};

View file

@ -135,7 +135,7 @@ if (typeof window !== 'undefined') {
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
);
} else {
window.location.href = '/login';
window.location.href = endpoints.loginPage();
}
} catch (err) {
processQueue(err as AxiosError, null);

View file

@ -0,0 +1,341 @@
import { anthropicSettings } from './schemas';
describe('anthropicSettings', () => {
describe('maxOutputTokens.reset()', () => {
const { reset } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet models', () => {
it('should return 64K for claude-sonnet-4', () => {
expect(reset('claude-sonnet-4')).toBe(64000);
});
it('should return 64K for claude-sonnet-4-5', () => {
expect(reset('claude-sonnet-4-5')).toBe(64000);
});
it('should return 64K for claude-sonnet-5', () => {
expect(reset('claude-sonnet-5')).toBe(64000);
});
it('should return 64K for future versions like claude-sonnet-9', () => {
expect(reset('claude-sonnet-9')).toBe(64000);
});
});
describe('Claude Haiku models', () => {
it('should return 64K for claude-haiku-4-5', () => {
expect(reset('claude-haiku-4-5')).toBe(64000);
});
it('should return 64K for claude-haiku-4', () => {
expect(reset('claude-haiku-4')).toBe(64000);
});
it('should return 64K for claude-haiku-5', () => {
expect(reset('claude-haiku-5')).toBe(64000);
});
it('should return 64K for future versions like claude-haiku-9', () => {
expect(reset('claude-haiku-9')).toBe(64000);
});
});
describe('Claude Opus 4.0-4.4 models (32K limit)', () => {
it('should return 32K for claude-opus-4', () => {
expect(reset('claude-opus-4')).toBe(32000);
});
it('should return 32K for claude-opus-4-0', () => {
expect(reset('claude-opus-4-0')).toBe(32000);
});
it('should return 32K for claude-opus-4-1', () => {
expect(reset('claude-opus-4-1')).toBe(32000);
});
it('should return 32K for claude-opus-4-2', () => {
expect(reset('claude-opus-4-2')).toBe(32000);
});
it('should return 32K for claude-opus-4-3', () => {
expect(reset('claude-opus-4-3')).toBe(32000);
});
it('should return 32K for claude-opus-4-4', () => {
expect(reset('claude-opus-4-4')).toBe(32000);
});
it('should return 32K for claude-opus-4.0', () => {
expect(reset('claude-opus-4.0')).toBe(32000);
});
it('should return 32K for claude-opus-4.1', () => {
expect(reset('claude-opus-4.1')).toBe(32000);
});
});
describe('Claude Opus 4.5+ models (64K limit - future-proof)', () => {
it('should return 64K for claude-opus-4-5', () => {
expect(reset('claude-opus-4-5')).toBe(64000);
});
it('should return 64K for claude-opus-4-6', () => {
expect(reset('claude-opus-4-6')).toBe(64000);
});
it('should return 64K for claude-opus-4-7', () => {
expect(reset('claude-opus-4-7')).toBe(64000);
});
it('should return 64K for claude-opus-4-8', () => {
expect(reset('claude-opus-4-8')).toBe(64000);
});
it('should return 64K for claude-opus-4-9', () => {
expect(reset('claude-opus-4-9')).toBe(64000);
});
it('should return 64K for claude-opus-4.5', () => {
expect(reset('claude-opus-4.5')).toBe(64000);
});
it('should return 64K for claude-opus-4.6', () => {
expect(reset('claude-opus-4.6')).toBe(64000);
});
});
describe('Claude Opus 4.10+ models (double-digit minor versions)', () => {
it('should return 64K for claude-opus-4-10', () => {
expect(reset('claude-opus-4-10')).toBe(64000);
});
it('should return 64K for claude-opus-4-11', () => {
expect(reset('claude-opus-4-11')).toBe(64000);
});
it('should return 64K for claude-opus-4-15', () => {
expect(reset('claude-opus-4-15')).toBe(64000);
});
it('should return 64K for claude-opus-4-20', () => {
expect(reset('claude-opus-4-20')).toBe(64000);
});
it('should return 64K for claude-opus-4.10', () => {
expect(reset('claude-opus-4.10')).toBe(64000);
});
});
describe('Claude Opus 5+ models (future major versions)', () => {
it('should return 64K for claude-opus-5', () => {
expect(reset('claude-opus-5')).toBe(64000);
});
it('should return 64K for claude-opus-6', () => {
expect(reset('claude-opus-6')).toBe(64000);
});
it('should return 64K for claude-opus-7', () => {
expect(reset('claude-opus-7')).toBe(64000);
});
it('should return 64K for claude-opus-9', () => {
expect(reset('claude-opus-9')).toBe(64000);
});
it('should return 64K for claude-opus-5-0', () => {
expect(reset('claude-opus-5-0')).toBe(64000);
});
it('should return 64K for claude-opus-5.0', () => {
expect(reset('claude-opus-5.0')).toBe(64000);
});
});
describe('Model name variations with dates and suffixes', () => {
it('should return 64K for claude-opus-4-5-20250420', () => {
expect(reset('claude-opus-4-5-20250420')).toBe(64000);
});
it('should return 64K for claude-opus-4-6-20260101', () => {
expect(reset('claude-opus-4-6-20260101')).toBe(64000);
});
it('should return 32K for claude-opus-4-1-20250805', () => {
expect(reset('claude-opus-4-1-20250805')).toBe(32000);
});
it('should return 32K for claude-opus-4-0-20240229', () => {
expect(reset('claude-opus-4-0-20240229')).toBe(32000);
});
});
describe('Legacy Claude models', () => {
it('should return 8192 for claude-3-opus', () => {
expect(reset('claude-3-opus')).toBe(8192);
});
it('should return 8192 for claude-3-5-sonnet', () => {
expect(reset('claude-3-5-sonnet')).toBe(8192);
});
it('should return 8192 for claude-3-5-haiku', () => {
expect(reset('claude-3-5-haiku')).toBe(8192);
});
it('should return 8192 for claude-3-7-sonnet', () => {
expect(reset('claude-3-7-sonnet')).toBe(8192);
});
it('should return 8192 for claude-2', () => {
expect(reset('claude-2')).toBe(8192);
});
it('should return 8192 for claude-2.1', () => {
expect(reset('claude-2.1')).toBe(8192);
});
it('should return 8192 for claude-instant', () => {
expect(reset('claude-instant')).toBe(8192);
});
});
describe('Non-Claude models and edge cases', () => {
it('should return 8192 for unknown model', () => {
expect(reset('unknown-model')).toBe(8192);
});
it('should return 8192 for empty string', () => {
expect(reset('')).toBe(8192);
});
it('should return 8192 for gpt-4', () => {
expect(reset('gpt-4')).toBe(8192);
});
it('should return 8192 for gemini-pro', () => {
expect(reset('gemini-pro')).toBe(8192);
});
});
describe('Regex pattern edge cases', () => {
it('should not match claude-opus-3', () => {
expect(reset('claude-opus-3')).toBe(8192);
});
it('should not match opus-4-5 without claude prefix', () => {
expect(reset('opus-4-5')).toBe(8192);
});
it('should NOT match claude.opus.4.5 (incorrect separator pattern)', () => {
// Model names use hyphens after "claude", not dots
expect(reset('claude.opus.4.5')).toBe(8192);
});
it('should match claude-opus45 (no separator after opus)', () => {
// The regex allows optional separators, so "45" can follow directly
// In practice, Anthropic uses separators, but regex is permissive
expect(reset('claude-opus45')).toBe(64000);
});
});
});
describe('maxOutputTokens.set()', () => {
const { set } = anthropicSettings.maxOutputTokens;
describe('Claude Sonnet and Haiku 4+ models (64K cap)', () => {
it('should cap at 64K for claude-sonnet-4 when value exceeds', () => {
expect(set(100000, 'claude-sonnet-4')).toBe(64000);
});
it('should allow 50K for claude-sonnet-4', () => {
expect(set(50000, 'claude-sonnet-4')).toBe(50000);
});
it('should cap at 64K for claude-haiku-4-5 when value exceeds', () => {
expect(set(80000, 'claude-haiku-4-5')).toBe(64000);
});
});
describe('Claude Opus 4.5+ models (64K cap)', () => {
it('should cap at 64K for claude-opus-4-5 when value exceeds', () => {
expect(set(100000, 'claude-opus-4-5')).toBe(64000);
});
it('should cap at model-specific 64K limit, not global 128K limit', () => {
// Values between 64K and 128K should be capped at 64K (model limit)
// This verifies the fix for the unreachable code issue
expect(set(70000, 'claude-opus-4-5')).toBe(64000);
expect(set(80000, 'claude-opus-4-5')).toBe(64000);
expect(set(100000, 'claude-opus-4-5')).toBe(64000);
expect(set(128000, 'claude-opus-4-5')).toBe(64000);
// Values above 128K should also be capped at 64K (not 128K)
expect(set(150000, 'claude-opus-4-5')).toBe(64000);
});
it('should allow 50K for claude-opus-4-5', () => {
expect(set(50000, 'claude-opus-4-5')).toBe(50000);
});
it('should cap at 64K for claude-opus-4-6', () => {
expect(set(80000, 'claude-opus-4-6')).toBe(64000);
});
it('should cap at 64K for claude-opus-5', () => {
expect(set(100000, 'claude-opus-5')).toBe(64000);
});
it('should cap at 64K for claude-opus-4-10', () => {
expect(set(100000, 'claude-opus-4-10')).toBe(64000);
});
});
describe('Claude Opus 4.0-4.4 models (32K cap)', () => {
it('should cap at 32K for claude-opus-4', () => {
expect(set(50000, 'claude-opus-4')).toBe(32000);
});
it('should allow 20K for claude-opus-4', () => {
expect(set(20000, 'claude-opus-4')).toBe(20000);
});
it('should cap at 32K for claude-opus-4-1', () => {
expect(set(50000, 'claude-opus-4-1')).toBe(32000);
});
it('should cap at 32K for claude-opus-4-4', () => {
expect(set(40000, 'claude-opus-4-4')).toBe(32000);
});
});
describe('Global 128K cap for all models', () => {
it('should cap at model-specific limit first, then global', () => {
// claude-sonnet-4 has 64K limit, so caps at 64K not 128K
expect(set(150000, 'claude-sonnet-4')).toBe(64000);
});
it('should cap at 128K for claude-3 models', () => {
expect(set(150000, 'claude-3-opus')).toBe(128000);
});
it('should cap at 128K for unknown models', () => {
expect(set(200000, 'unknown-model')).toBe(128000);
});
});
describe('Valid values within limits', () => {
it('should allow valid values for legacy models', () => {
expect(set(8000, 'claude-3-opus')).toBe(8000);
});
it('should allow 1 token minimum', () => {
expect(set(1, 'claude-opus-4-5')).toBe(1);
});
it('should allow 128K exactly', () => {
expect(set(128000, 'claude-3-opus')).toBe(128000);
});
});
});
});

View file

@ -41,7 +41,6 @@ export enum Providers {
BEDROCK = 'bedrock',
MISTRALAI = 'mistralai',
MISTRAL = 'mistral',
OLLAMA = 'ollama',
DEEPSEEK = 'deepseek',
OPENROUTER = 'openrouter',
XAI = 'xai',
@ -59,7 +58,6 @@ export const documentSupportedProviders = new Set<string>([
Providers.VERTEXAI,
Providers.MISTRALAI,
Providers.MISTRAL,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.XAI,
@ -71,7 +69,6 @@ const openAILikeProviders = new Set<string>([
EModelEndpoint.custom,
Providers.MISTRALAI,
Providers.MISTRAL,
Providers.OLLAMA,
Providers.DEEPSEEK,
Providers.OPENROUTER,
Providers.XAI,
@ -386,6 +383,10 @@ export const anthropicSettings = {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-opus[-.]?(?:[5-9]|4[-.]?([5-9]|\d{2,}))/.test(modelName)) {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-opus[-.]?[4-9]/.test(modelName)) {
return CLAUDE_32K_MAX_OUTPUT;
}
@ -397,7 +398,14 @@ export const anthropicSettings = {
return CLAUDE_4_64K_MAX_OUTPUT;
}
if (/claude-(?:opus|haiku)[-.]?[4-9]/.test(modelName) && value > CLAUDE_32K_MAX_OUTPUT) {
if (/claude-opus[-.]?(?:[5-9]|4[-.]?([5-9]|\d{2,}))/.test(modelName)) {
if (value > CLAUDE_4_64K_MAX_OUTPUT) {
return CLAUDE_4_64K_MAX_OUTPUT;
}
return value;
}
if (/claude-opus[-.]?[4-9]/.test(modelName) && value > CLAUDE_32K_MAX_OUTPUT) {
return CLAUDE_32K_MAX_OUTPUT;
}
@ -610,6 +618,8 @@ export const tMessageSchema = z.object({
/* frontend components */
iconURL: z.string().nullable().optional(),
feedback: feedbackSchema.optional(),
/** metadata */
metadata: z.record(z.unknown()).optional(),
});
export type MemoryArtifact = {

View file

@ -185,8 +185,8 @@ export interface MCPConnectionStatusResponse {
export interface MCPServerConnectionStatusResponse {
success: boolean;
serverName: string;
connectionStatus: string;
requiresOAuth: boolean;
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
}
export interface MCPAuthValuesResponse {