mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-18 00:18:09 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
23ac2556da
193 changed files with 3845 additions and 692 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/api",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"type": "commonjs",
|
||||
"description": "MCP services for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/agents": "^2.4.90",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.12.1",
|
||||
|
|
|
|||
|
|
@ -383,9 +383,11 @@ ${memory ?? 'No existing memories'}`;
|
|||
});
|
||||
|
||||
const config = {
|
||||
runName: 'MemoryRun',
|
||||
configurable: {
|
||||
user_id: userId,
|
||||
thread_id: conversationId,
|
||||
provider: llmConfig?.provider,
|
||||
thread_id: `memory-run-${conversationId}`,
|
||||
},
|
||||
streamMode: 'values',
|
||||
recursionLimit: 3,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,27 @@ describe('isEmailDomainAllowed', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false if email is falsy', async () => {
|
||||
it('should return true if email is falsy and no domain restrictions exist', async () => {
|
||||
const email = '';
|
||||
const result = isEmailDomainAllowed(email);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if domain is not present in the email and no domain restrictions exist', async () => {
|
||||
const email = 'test';
|
||||
const result = isEmailDomainAllowed(email);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if email is falsy and domain restrictions exist', async () => {
|
||||
const email = '';
|
||||
const result = isEmailDomainAllowed(email, ['domain1.com']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if domain is not present in the email', async () => {
|
||||
it('should return false if domain is not present in the email and domain restrictions exist', async () => {
|
||||
const email = 'test';
|
||||
const result = isEmailDomainAllowed(email);
|
||||
const result = isEmailDomainAllowed(email, ['domain1.com']);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
* @param allowedDomains
|
||||
*/
|
||||
export function isEmailDomainAllowed(email: string, allowedDomains?: string[] | null): boolean {
|
||||
/** If no domain restrictions are configured, allow all */
|
||||
if (!allowedDomains || !Array.isArray(allowedDomains) || !allowedDomains.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** If restrictions exist, validate email format */
|
||||
if (!email) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -13,12 +19,6 @@ export function isEmailDomainAllowed(email: string, allowedDomains?: string[] |
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!allowedDomains) {
|
||||
return true;
|
||||
} else if (!Array.isArray(allowedDomains) || !allowedDomains.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allowedDomains.some((allowedDomain) => allowedDomain?.toLowerCase() === domain);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,8 +245,8 @@ describe('getLLMConfig', () => {
|
|||
},
|
||||
});
|
||||
|
||||
// The actual anthropicSettings.maxOutputTokens.reset('claude-3-opus') returns 4096
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 4096);
|
||||
// The actual anthropicSettings.maxOutputTokens.reset('claude-3-opus') returns 8192
|
||||
expect(result.llmConfig).toHaveProperty('maxTokens', 8192);
|
||||
});
|
||||
|
||||
it('should handle both proxy and reverseProxyUrl', () => {
|
||||
|
|
@ -698,9 +698,17 @@ describe('getLLMConfig', () => {
|
|||
{ model: 'claude-3.5-sonnet-20241022', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3-7-sonnet', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3.7-sonnet-20250109', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3-opus', expectedMaxTokens: 4096 },
|
||||
{ model: 'claude-3-haiku', expectedMaxTokens: 4096 },
|
||||
{ model: 'claude-2.1', expectedMaxTokens: 4096 },
|
||||
{ model: 'claude-3-opus', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3-haiku', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-2.1', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-sonnet-4-5', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-sonnet-4-5-20250929', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-haiku-4-5', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-haiku-4-5-20251001', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-opus-4-1', expectedMaxTokens: 32000 },
|
||||
{ model: 'claude-opus-4-1-20250805', expectedMaxTokens: 32000 },
|
||||
{ model: 'claude-sonnet-4-20250514', expectedMaxTokens: 64000 },
|
||||
{ model: 'claude-opus-4-0', expectedMaxTokens: 32000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, expectedMaxTokens }) => {
|
||||
|
|
@ -729,6 +737,222 @@ describe('getLLMConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Claude 4.x Model maxOutputTokens Defaults', () => {
|
||||
it('should default Claude Sonnet 4.x models to 64K tokens', () => {
|
||||
const testCases = ['claude-sonnet-4-5', 'claude-sonnet-4-5-20250929', 'claude-sonnet-4.5'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default Claude Haiku 4.x models to 64K tokens', () => {
|
||||
const testCases = ['claude-haiku-4-5', 'claude-haiku-4-5-20251001', 'claude-haiku-4.5'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default Claude Opus 4.x models to 32K tokens', () => {
|
||||
const testCases = ['claude-opus-4-1', 'claude-opus-4-1-20250805', 'claude-opus-4.1'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default future Claude 4.x Sonnet/Haiku models to 64K (future-proofing)', () => {
|
||||
const testCases = ['claude-sonnet-4-20250514', 'claude-sonnet-4-9', 'claude-haiku-4-8'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should default future Claude 4.x Opus models to 32K (future-proofing)', () => {
|
||||
const testCases = ['claude-opus-4-0', 'claude-opus-4-7'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle explicit maxOutputTokens override for Claude 4.x models', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
maxOutputTokens: 64000, // Explicitly set to 64K
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
|
||||
it('should handle undefined maxOutputTokens for Claude 4.x (use reset default)', () => {
|
||||
const testCases = [
|
||||
{ model: 'claude-sonnet-4-5', expected: 64000 },
|
||||
{ model: 'claude-haiku-4-5', expected: 64000 },
|
||||
{ model: 'claude-opus-4-1', expected: 32000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model,
|
||||
maxOutputTokens: undefined,
|
||||
},
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Claude 4 Sonnet/Haiku with thinking enabled', () => {
|
||||
const testCases = ['claude-sonnet-4-5', 'claude-haiku-4-5'];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model,
|
||||
thinking: true,
|
||||
thinkingBudget: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.thinking).toMatchObject({
|
||||
type: 'enabled',
|
||||
budget_tokens: 10000,
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Claude 4 Opus with thinking enabled', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-opus-4-1',
|
||||
thinking: true,
|
||||
thinkingBudget: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig.thinking).toMatchObject({
|
||||
type: 'enabled',
|
||||
budget_tokens: 10000,
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
});
|
||||
|
||||
it('should respect model-specific maxOutputTokens for Claude 4.x models', () => {
|
||||
const testCases = [
|
||||
{ model: 'claude-sonnet-4-5', maxOutputTokens: 50000, expected: 50000 },
|
||||
{ model: 'claude-haiku-4-5', maxOutputTokens: 40000, expected: 40000 },
|
||||
{ model: 'claude-opus-4-1', maxOutputTokens: 20000, expected: 20000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, maxOutputTokens, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model,
|
||||
maxOutputTokens,
|
||||
},
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 5.x Sonnet models with 64K default', () => {
|
||||
const testCases = [
|
||||
'claude-sonnet-5',
|
||||
'claude-sonnet-5-0',
|
||||
'claude-sonnet-5-2-20260101',
|
||||
'claude-sonnet-5.5',
|
||||
];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 5.x Haiku models with 64K default', () => {
|
||||
const testCases = [
|
||||
'claude-haiku-5',
|
||||
'claude-haiku-5-0',
|
||||
'claude-haiku-5-2-20260101',
|
||||
'claude-haiku-5.5',
|
||||
];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(64000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 5.x Opus models with 32K default', () => {
|
||||
const testCases = [
|
||||
'claude-opus-5',
|
||||
'claude-opus-5-0',
|
||||
'claude-opus-5-2-20260101',
|
||||
'claude-opus-5.5',
|
||||
];
|
||||
|
||||
testCases.forEach((model) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
it('should future-proof Claude 6-9.x models with correct defaults', () => {
|
||||
const testCases = [
|
||||
// Claude 6.x
|
||||
{ model: 'claude-sonnet-6', expected: 64000 },
|
||||
{ model: 'claude-haiku-6-0', expected: 64000 },
|
||||
{ model: 'claude-opus-6-1', expected: 32000 },
|
||||
// Claude 7.x
|
||||
{ model: 'claude-sonnet-7-20270101', expected: 64000 },
|
||||
{ model: 'claude-haiku-7.5', expected: 64000 },
|
||||
{ model: 'claude-opus-7', expected: 32000 },
|
||||
// Claude 8.x
|
||||
{ model: 'claude-sonnet-8', expected: 64000 },
|
||||
{ model: 'claude-haiku-8-2', expected: 64000 },
|
||||
{ model: 'claude-opus-8-latest', expected: 32000 },
|
||||
// Claude 9.x
|
||||
{ model: 'claude-sonnet-9', expected: 64000 },
|
||||
{ model: 'claude-haiku-9', expected: 64000 },
|
||||
{ model: 'claude-opus-9', expected: 32000 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Boundary and Validation Logic', () => {
|
||||
it('should handle temperature boundary values', () => {
|
||||
const testCases = [
|
||||
|
|
@ -784,7 +1008,7 @@ describe('getLLMConfig', () => {
|
|||
it('should handle maxOutputTokens boundary values', () => {
|
||||
const testCases = [
|
||||
{ model: 'claude-3-opus', maxOutputTokens: 1, expected: 1 }, // min
|
||||
{ model: 'claude-3-opus', maxOutputTokens: 4096, expected: 4096 }, // max for legacy
|
||||
{ model: 'claude-3-opus', maxOutputTokens: 8192, expected: 8192 }, // default for claude-3
|
||||
{ model: 'claude-3-5-sonnet', maxOutputTokens: 1, expected: 1 }, // min
|
||||
{ model: 'claude-3-5-sonnet', maxOutputTokens: 200000, expected: 200000 }, // max for new
|
||||
{ model: 'claude-3-7-sonnet', maxOutputTokens: 8192, expected: 8192 }, // default
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ function getLLMConfig(
|
|||
|
||||
const defaultOptions = {
|
||||
model: anthropicSettings.model.default,
|
||||
maxOutputTokens: anthropicSettings.maxOutputTokens.default,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('getOpenAIConfig - Anthropic Compatibility', () => {
|
|||
apiKey: 'sk-xxxx',
|
||||
model: 'claude-sonnet-4',
|
||||
stream: true,
|
||||
maxTokens: 8192,
|
||||
maxTokens: 64000,
|
||||
modelKwargs: {
|
||||
metadata: {
|
||||
user_id: 'some_user_id',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Verbosity, ReasoningEffort, ReasoningSummary } from 'librechat-data-provider';
|
||||
import {
|
||||
Verbosity,
|
||||
EModelEndpoint,
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
} from 'librechat-data-provider';
|
||||
import type { RequestInit } from 'undici';
|
||||
import type { OpenAIParameters, AzureOptions } from '~/types';
|
||||
import { getOpenAIConfig } from './config';
|
||||
|
|
@ -103,12 +108,89 @@ describe('getOpenAIConfig', () => {
|
|||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions });
|
||||
|
||||
/** When no endpoint is specified, it's treated as non-openAI/azureOpenAI, so uses reasoning object */
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use reasoning_effort for openAI endpoint without useResponsesApi', () => {
|
||||
const modelOptions = {
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions }, EModelEndpoint.openAI);
|
||||
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBe(
|
||||
ReasoningEffort.high,
|
||||
);
|
||||
expect(result.llmConfig.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use reasoning_effort for azureOpenAI endpoint without useResponsesApi', () => {
|
||||
const modelOptions = {
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions }, EModelEndpoint.azureOpenAI);
|
||||
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBe(
|
||||
ReasoningEffort.high,
|
||||
);
|
||||
expect(result.llmConfig.reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use reasoning object for openAI endpoint with useResponsesApi=true', () => {
|
||||
const modelOptions = {
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions }, EModelEndpoint.openAI);
|
||||
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use reasoning object for azureOpenAI endpoint with useResponsesApi=true', () => {
|
||||
const modelOptions = {
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
useResponsesApi: true,
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions }, EModelEndpoint.azureOpenAI);
|
||||
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use reasoning object for non-openAI/azureOpenAI endpoints', () => {
|
||||
const modelOptions = {
|
||||
reasoning_effort: ReasoningEffort.high,
|
||||
reasoning_summary: ReasoningSummary.detailed,
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, { modelOptions }, 'custom-endpoint');
|
||||
|
||||
expect(result.llmConfig.reasoning).toEqual({
|
||||
effort: ReasoningEffort.high,
|
||||
summary: ReasoningSummary.detailed,
|
||||
});
|
||||
expect((result.llmConfig as Record<string, unknown>).reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle OpenRouter configuration', () => {
|
||||
const reverseProxyUrl = 'https://openrouter.ai/api/v1';
|
||||
|
||||
|
|
@ -655,6 +737,27 @@ describe('getOpenAIConfig', () => {
|
|||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create correct Azure baseURL when response api is selected', () => {
|
||||
const azure = {
|
||||
azureOpenAIApiInstanceName: 'test-instance',
|
||||
azureOpenAIApiDeploymentName: 'test-deployment',
|
||||
azureOpenAIApiVersion: '2023-08-15',
|
||||
azureOpenAIApiKey: 'azure-key',
|
||||
};
|
||||
|
||||
const result = getOpenAIConfig(mockApiKey, {
|
||||
azure,
|
||||
modelOptions: { useResponsesApi: true },
|
||||
reverseProxyUrl:
|
||||
'https://${INSTANCE_NAME}.openai.azure.com/openai/deployments/${DEPLOYMENT_NAME}',
|
||||
});
|
||||
|
||||
expect(result.configOptions?.baseURL).toBe(
|
||||
'https://test-instance.openai.azure.com/openai/v1',
|
||||
);
|
||||
expect(result.configOptions?.baseURL).not.toContain('deployments');
|
||||
});
|
||||
|
||||
it('should handle Azure with organization from environment', () => {
|
||||
const originalOrg = process.env.OPENAI_ORGANIZATION;
|
||||
process.env.OPENAI_ORGANIZATION = 'test-org-123';
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export function getOpenAIConfig(
|
|||
azure,
|
||||
apiKey,
|
||||
baseURL,
|
||||
endpoint,
|
||||
streaming,
|
||||
addParams,
|
||||
dropParams,
|
||||
|
|
@ -112,8 +113,10 @@ export function getOpenAIConfig(
|
|||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = configOptions.baseURL?.replace(/\/deployments(?:\/.*)?$/, '/v1');
|
||||
|
||||
configOptions.baseURL = constructAzureURL({
|
||||
baseURL: configOptions.baseURL || 'https://${INSTANCE_NAME}.openai.azure.com/openai/v1',
|
||||
baseURL: updatedUrl || 'https://${INSTANCE_NAME}.openai.azure.com/openai/v1',
|
||||
azureOptions: azure,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { removeNullishValues } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider';
|
||||
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
|
||||
import type { AzureOpenAIInput } from '@langchain/openai';
|
||||
import type { OpenAI } from 'openai';
|
||||
|
|
@ -79,6 +79,7 @@ export function getOpenAILLMConfig({
|
|||
azure,
|
||||
apiKey,
|
||||
baseURL,
|
||||
endpoint,
|
||||
streaming,
|
||||
addParams,
|
||||
dropParams,
|
||||
|
|
@ -88,6 +89,7 @@ export function getOpenAILLMConfig({
|
|||
apiKey: string;
|
||||
streaming: boolean;
|
||||
baseURL?: string | null;
|
||||
endpoint?: EModelEndpoint | string | null;
|
||||
modelOptions: Partial<t.OpenAIParameters>;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
|
|
@ -155,7 +157,8 @@ export function getOpenAILLMConfig({
|
|||
|
||||
if (
|
||||
hasReasoningParams({ reasoning_effort, reasoning_summary }) &&
|
||||
(llmConfig.useResponsesApi === true || useOpenRouter)
|
||||
(llmConfig.useResponsesApi === true ||
|
||||
(endpoint !== EModelEndpoint.openAI && endpoint !== EModelEndpoint.azureOpenAI))
|
||||
) {
|
||||
llmConfig.reasoning = removeNullishValues(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Providers } from '@librechat/agents';
|
|||
import { isOpenAILikeProvider, isDocumentSupportedProvider } from 'librechat-data-provider';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { Request } from 'express';
|
||||
import type { StrategyFunctions, DocumentResult } from '~/types/files';
|
||||
import type { StrategyFunctions, DocumentResult, AnthropicDocumentBlock } from '~/types/files';
|
||||
import { validatePdf } from '~/files/validation';
|
||||
import { getFileStream } from './utils';
|
||||
|
||||
|
|
@ -69,16 +69,21 @@ export async function encodeAndFormatDocuments(
|
|||
}
|
||||
|
||||
if (provider === Providers.ANTHROPIC) {
|
||||
result.documents.push({
|
||||
const document: AnthropicDocumentBlock = {
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: content,
|
||||
},
|
||||
cache_control: { type: 'ephemeral' },
|
||||
citations: { enabled: true },
|
||||
});
|
||||
};
|
||||
|
||||
if (file.filename) {
|
||||
document.context = `File: "${file.filename}"`;
|
||||
}
|
||||
|
||||
result.documents.push(document);
|
||||
} else if (useResponsesApi) {
|
||||
result.documents.push({
|
||||
type: 'input_file',
|
||||
|
|
|
|||
|
|
@ -46,29 +46,51 @@ export interface VideoResult {
|
|||
}>;
|
||||
}
|
||||
|
||||
/** Anthropic document block format */
|
||||
export interface AnthropicDocumentBlock {
|
||||
type: 'document';
|
||||
source: {
|
||||
type: string;
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
context?: string;
|
||||
title?: string;
|
||||
cache_control?: { type: string };
|
||||
citations?: { enabled: boolean };
|
||||
}
|
||||
|
||||
/** Google document block format */
|
||||
export interface GoogleDocumentBlock {
|
||||
type: 'document';
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** OpenAI file block format */
|
||||
export interface OpenAIFileBlock {
|
||||
type: 'file';
|
||||
file: {
|
||||
filename: string;
|
||||
file_data: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAI Responses API file format */
|
||||
export interface OpenAIInputFileBlock {
|
||||
type: 'input_file';
|
||||
filename: string;
|
||||
file_data: string;
|
||||
}
|
||||
|
||||
export type DocumentBlock =
|
||||
| AnthropicDocumentBlock
|
||||
| GoogleDocumentBlock
|
||||
| OpenAIFileBlock
|
||||
| OpenAIInputFileBlock;
|
||||
|
||||
export interface DocumentResult {
|
||||
documents: Array<{
|
||||
type: 'document' | 'file' | 'input_file';
|
||||
/** Anthropic File Format, `document` */
|
||||
source?: {
|
||||
type: string;
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
cache_control?: { type: string };
|
||||
citations?: { enabled: boolean };
|
||||
/** Google File Format, `document` */
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
/** OpenAI File Format, `file` */
|
||||
file?: {
|
||||
filename?: string;
|
||||
file_data?: string;
|
||||
};
|
||||
/** OpenAI Responses API File Format, `input_file` */
|
||||
filename?: string;
|
||||
file_data?: string;
|
||||
}>;
|
||||
documents: DocumentBlock[];
|
||||
files: Array<{
|
||||
file_id?: string;
|
||||
temp_file_id?: string;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export * from './key';
|
|||
export * from './llm';
|
||||
export * from './math';
|
||||
export * from './openid';
|
||||
export * from './sanitizeTitle';
|
||||
export * from './tempChatRetention';
|
||||
export * from './text';
|
||||
export { default as Tokenizer } from './tokenizer';
|
||||
|
|
|
|||
217
packages/api/src/utils/sanitizeTitle.spec.ts
Normal file
217
packages/api/src/utils/sanitizeTitle.spec.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { sanitizeTitle } from './sanitizeTitle';
|
||||
|
||||
describe('sanitizeTitle', () => {
|
||||
describe('Happy Path', () => {
|
||||
it('should remove a single think block and return the clean title', () => {
|
||||
const input = '<think>This is reasoning about the topic</think> User Hi Greeting';
|
||||
expect(sanitizeTitle(input)).toBe('User Hi Greeting');
|
||||
});
|
||||
|
||||
it('should handle thinking block at the start', () => {
|
||||
const input = '<think>reasoning here</think> Clean Title Text';
|
||||
expect(sanitizeTitle(input)).toBe('Clean Title Text');
|
||||
});
|
||||
|
||||
it('should handle thinking block at the end', () => {
|
||||
const input = 'Clean Title Text <think>reasoning here</think>';
|
||||
expect(sanitizeTitle(input)).toBe('Clean Title Text');
|
||||
});
|
||||
|
||||
it('should handle title without any thinking blocks', () => {
|
||||
const input = 'Just a Normal Title';
|
||||
expect(sanitizeTitle(input)).toBe('Just a Normal Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Blocks', () => {
|
||||
it('should remove multiple think blocks', () => {
|
||||
const input =
|
||||
'<think>reason 1</think> Intro <think>reason 2</think> Middle <think>reason 3</think> Final';
|
||||
expect(sanitizeTitle(input)).toBe('Intro Middle Final');
|
||||
});
|
||||
|
||||
it('should handle consecutive think blocks', () => {
|
||||
const input = '<think>r1</think><think>r2</think>Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Case Insensitivity', () => {
|
||||
it('should handle uppercase THINK tags', () => {
|
||||
const input = '<THINK>reasoning</THINK> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should handle mixed case Think tags', () => {
|
||||
const input = '<Think>reasoning</ThInk> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should handle mixed case closing tag', () => {
|
||||
const input = '<think>reasoning</THINK> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attributes in Tags', () => {
|
||||
it('should remove think tags with attributes', () => {
|
||||
const input = '<think reason="complex logic">reasoning here</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should handle multiple attributes', () => {
|
||||
const input =
|
||||
'<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should handle single-quoted attributes', () => {
|
||||
const input = "<think reason='explanation'>content</think> Title";
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should handle unquoted attributes', () => {
|
||||
const input = '<think x=y>reasoning</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Newlines and Multiline Content', () => {
|
||||
it('should handle newlines within the think block', () => {
|
||||
const input = `<think>
|
||||
This is a long reasoning
|
||||
spanning multiple lines
|
||||
with various thoughts
|
||||
</think> Clean Title`;
|
||||
expect(sanitizeTitle(input)).toBe('Clean Title');
|
||||
});
|
||||
|
||||
it('should handle newlines around tags', () => {
|
||||
const input = `
|
||||
<think>reasoning</think>
|
||||
My Title
|
||||
`;
|
||||
expect(sanitizeTitle(input)).toBe('My Title');
|
||||
});
|
||||
|
||||
it('should handle mixed whitespace', () => {
|
||||
const input = '<think>\n\t reasoning \t\n</think>\n Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Whitespace Normalization', () => {
|
||||
it('should collapse multiple spaces', () => {
|
||||
const input = '<think>x</think> Multiple Spaces';
|
||||
expect(sanitizeTitle(input)).toBe('Multiple Spaces');
|
||||
});
|
||||
|
||||
it('should collapse mixed whitespace', () => {
|
||||
const input = 'Start \n\t Middle <think>x</think> \n End';
|
||||
expect(sanitizeTitle(input)).toBe('Start Middle End');
|
||||
});
|
||||
|
||||
it('should trim leading whitespace', () => {
|
||||
const input = ' <think>reasoning</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
it('should trim trailing whitespace', () => {
|
||||
const input = 'Title <think>reasoning</think> \n ';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty and Fallback Cases', () => {
|
||||
it('should return fallback for empty string', () => {
|
||||
expect(sanitizeTitle('')).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should return fallback when only whitespace remains', () => {
|
||||
const input = '<think>thinking</think> \n\t\r\n ';
|
||||
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should return fallback when only think blocks exist', () => {
|
||||
const input = '<think>just thinking</think><think>more thinking</think>';
|
||||
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
||||
});
|
||||
|
||||
it('should return fallback for non-string whitespace', () => {
|
||||
expect(sanitizeTitle(' ')).toBe('Untitled Conversation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Real-World', () => {
|
||||
it('should handle long reasoning blocks', () => {
|
||||
const longReasoning =
|
||||
'This is a very long reasoning block ' + 'with lots of text. '.repeat(50);
|
||||
const input = `<think>${longReasoning}</think> Final Title`;
|
||||
expect(sanitizeTitle(input)).toBe('Final Title');
|
||||
});
|
||||
|
||||
it('should handle nested-like patterns', () => {
|
||||
const input = '<think>outer <think>inner</think> end</think> Title';
|
||||
const result = sanitizeTitle(input);
|
||||
expect(result).toContain('Title');
|
||||
});
|
||||
|
||||
it('should handle malformed tags missing closing', () => {
|
||||
const input = '<think>unclosed reasoning. Title';
|
||||
const result = sanitizeTitle(input);
|
||||
expect(result).toContain('Title');
|
||||
expect(result).toContain('<think>');
|
||||
});
|
||||
|
||||
it('should handle real-world LLM example', () => {
|
||||
const input =
|
||||
'<think>\nThe user is asking for a greeting. I should provide a friendly response.\n</think> User Hi Greeting';
|
||||
expect(sanitizeTitle(input)).toBe('User Hi Greeting');
|
||||
});
|
||||
|
||||
it('should handle real-world with attributes', () => {
|
||||
const input =
|
||||
'<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||
expect(sanitizeTitle(input)).toBe('Project Status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Idempotency', () => {
|
||||
it('should be idempotent', () => {
|
||||
const input = '<think>reasoning</think> My Title';
|
||||
const once = sanitizeTitle(input);
|
||||
const twice = sanitizeTitle(once);
|
||||
expect(once).toBe(twice);
|
||||
expect(once).toBe('My Title');
|
||||
});
|
||||
|
||||
it('should be idempotent with fallback', () => {
|
||||
const input = '<think>only thinking</think>';
|
||||
const once = sanitizeTitle(input);
|
||||
const twice = sanitizeTitle(once);
|
||||
expect(once).toBe(twice);
|
||||
expect(once).toBe('Untitled Conversation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Return Type Safety', () => {
|
||||
it('should always return a string', () => {
|
||||
expect(typeof sanitizeTitle('<think>x</think> Title')).toBe('string');
|
||||
expect(typeof sanitizeTitle('No blocks')).toBe('string');
|
||||
expect(typeof sanitizeTitle('')).toBe('string');
|
||||
});
|
||||
|
||||
it('should never return empty', () => {
|
||||
expect(sanitizeTitle('')).not.toBe('');
|
||||
expect(sanitizeTitle(' ')).not.toBe('');
|
||||
expect(sanitizeTitle('<think>x</think>')).not.toBe('');
|
||||
});
|
||||
|
||||
it('should never return null or undefined', () => {
|
||||
expect(sanitizeTitle('test')).not.toBeNull();
|
||||
expect(sanitizeTitle('test')).not.toBeUndefined();
|
||||
expect(sanitizeTitle('')).not.toBeNull();
|
||||
expect(sanitizeTitle('')).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
30
packages/api/src/utils/sanitizeTitle.ts
Normal file
30
packages/api/src/utils/sanitizeTitle.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Sanitizes LLM-generated chat titles by removing <think>...</think> reasoning blocks.
|
||||
*
|
||||
* This function strips out all reasoning blocks (with optional attributes and newlines)
|
||||
* and returns a clean title. If the result is empty, a fallback is returned.
|
||||
*
|
||||
* @param rawTitle - The raw LLM-generated title string, potentially containing <think> blocks.
|
||||
* @returns A sanitized title string, never empty (fallback used if needed).
|
||||
*/
|
||||
export function sanitizeTitle(rawTitle: string): string {
|
||||
const DEFAULT_FALLBACK = 'Untitled Conversation';
|
||||
|
||||
// Step 1: Input Validation
|
||||
if (!rawTitle || typeof rawTitle !== 'string') {
|
||||
return DEFAULT_FALLBACK;
|
||||
}
|
||||
|
||||
// Step 2: Build and apply the regex to remove all <think>...</think> blocks
|
||||
const thinkBlockRegex = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
|
||||
const cleaned = rawTitle.replace(thinkBlockRegex, '');
|
||||
|
||||
// Step 3: Normalize whitespace (collapse multiple spaces/newlines to single space)
|
||||
const normalized = cleaned.replace(/\s+/g, ' ');
|
||||
|
||||
// Step 4: Trim leading and trailing whitespace
|
||||
const trimmed = normalized.trim();
|
||||
|
||||
// Step 5: Return trimmed result or fallback if empty
|
||||
return trimmed.length > 0 ? trimmed : DEFAULT_FALLBACK;
|
||||
}
|
||||
|
|
@ -40,10 +40,10 @@ const openAIModels = {
|
|||
'gpt-5': 400000,
|
||||
'gpt-5-mini': 400000,
|
||||
'gpt-5-nano': 400000,
|
||||
'gpt-5-pro': 400000,
|
||||
'gpt-4o': 127500, // -500 from max
|
||||
'gpt-4o-mini': 127500, // -500 from max
|
||||
'gpt-4o-2024-05-13': 127500, // -500 from max
|
||||
'gpt-4o-2024-08-06': 127500, // -500 from max
|
||||
'gpt-4-turbo': 127500, // -500 from max
|
||||
'gpt-4-vision': 127500, // -500 from max
|
||||
'gpt-3.5-turbo': 16375, // -10 from max
|
||||
|
|
@ -60,9 +60,11 @@ const mistralModels = {
|
|||
'mistral-7b': 31990, // -10 from max
|
||||
'mistral-small': 31990, // -10 from max
|
||||
'mixtral-8x7b': 31990, // -10 from max
|
||||
'mixtral-8x22b': 65536,
|
||||
'mistral-large': 131000,
|
||||
'mistral-large-2402': 127500,
|
||||
'mistral-large-2407': 127500,
|
||||
'mistral-nemo': 131000,
|
||||
'pixtral-large': 131000,
|
||||
'mistral-saba': 32000,
|
||||
codestral: 256000,
|
||||
|
|
@ -75,6 +77,7 @@ const cohereModels = {
|
|||
'command-light-nightly': 8182, // -10 from max
|
||||
command: 4086, // -10 from max
|
||||
'command-nightly': 8182, // -10 from max
|
||||
'command-text': 4086, // -10 from max
|
||||
'command-r': 127500, // -500 from max
|
||||
'command-r-plus': 127500, // -500 from max
|
||||
};
|
||||
|
|
@ -127,14 +130,17 @@ const anthropicModels = {
|
|||
'claude-3.7-sonnet': 200000,
|
||||
'claude-3-5-sonnet-latest': 200000,
|
||||
'claude-3.5-sonnet-latest': 200000,
|
||||
'claude-haiku-4-5': 200000,
|
||||
'claude-sonnet-4': 1000000,
|
||||
'claude-opus-4': 200000,
|
||||
'claude-4': 200000,
|
||||
};
|
||||
|
||||
const deepseekModels = {
|
||||
'deepseek-reasoner': 128000,
|
||||
deepseek: 128000,
|
||||
'deepseek-reasoner': 128000,
|
||||
'deepseek-r1': 128000,
|
||||
'deepseek-v3': 128000,
|
||||
'deepseek.r1': 128000,
|
||||
};
|
||||
|
||||
|
|
@ -200,32 +206,57 @@ const metaModels = {
|
|||
'llama2:70b': 4000,
|
||||
};
|
||||
|
||||
const ollamaModels = {
|
||||
const qwenModels = {
|
||||
qwen: 32000,
|
||||
'qwen2.5': 32000,
|
||||
'qwen-turbo': 1000000,
|
||||
'qwen-plus': 131000,
|
||||
'qwen-max': 32000,
|
||||
'qwq-32b': 32000,
|
||||
// Qwen3 models
|
||||
qwen3: 40960, // Qwen3 base pattern (using qwen3-4b context)
|
||||
'qwen3-8b': 128000,
|
||||
'qwen3-14b': 40960,
|
||||
'qwen3-30b-a3b': 40960,
|
||||
'qwen3-32b': 40960,
|
||||
'qwen3-235b-a22b': 40960,
|
||||
// Qwen3 VL (Vision-Language) models
|
||||
'qwen3-vl-8b-thinking': 256000,
|
||||
'qwen3-vl-8b-instruct': 262144,
|
||||
'qwen3-vl-30b-a3b': 262144,
|
||||
'qwen3-vl-235b-a22b': 131072,
|
||||
// Qwen3 specialized models
|
||||
'qwen3-max': 256000,
|
||||
'qwen3-coder': 262144,
|
||||
'qwen3-coder-30b-a3b': 262144,
|
||||
'qwen3-coder-plus': 128000,
|
||||
'qwen3-coder-flash': 128000,
|
||||
'qwen3-next-80b-a3b': 262144,
|
||||
};
|
||||
|
||||
const ai21Models = {
|
||||
'ai21.j2-mid-v1': 8182, // -10 from max
|
||||
'ai21.j2-ultra-v1': 8182, // -10 from max
|
||||
'ai21.jamba-instruct-v1:0': 255500, // -500 from max
|
||||
'j2-mid': 8182, // -10 from max
|
||||
'j2-ultra': 8182, // -10 from max
|
||||
'jamba-instruct': 255500, // -500 from max
|
||||
};
|
||||
|
||||
const amazonModels = {
|
||||
'amazon.titan-text-lite-v1': 4000,
|
||||
'amazon.titan-text-express-v1': 8000,
|
||||
'amazon.titan-text-premier-v1:0': 31500, // -500 from max
|
||||
// Amazon Titan models
|
||||
'titan-text-lite': 4000,
|
||||
'titan-text-express': 8000,
|
||||
'titan-text-premier': 31500, // -500 from max
|
||||
// Amazon Nova models
|
||||
// https://aws.amazon.com/ai/generative-ai/nova/
|
||||
'amazon.nova-micro-v1:0': 127000, // -1000 from max,
|
||||
'amazon.nova-lite-v1:0': 295000, // -5000 from max,
|
||||
'amazon.nova-pro-v1:0': 295000, // -5000 from max,
|
||||
'amazon.nova-premier-v1:0': 995000, // -5000 from max,
|
||||
'nova-micro': 127000, // -1000 from max
|
||||
'nova-lite': 295000, // -5000 from max
|
||||
'nova-pro': 295000, // -5000 from max
|
||||
'nova-premier': 995000, // -5000 from max
|
||||
};
|
||||
|
||||
const bedrockModels = {
|
||||
...anthropicModels,
|
||||
...mistralModels,
|
||||
...cohereModels,
|
||||
...ollamaModels,
|
||||
...deepseekModels,
|
||||
...metaModels,
|
||||
...ai21Models,
|
||||
|
|
@ -254,6 +285,7 @@ const aggregateModels = {
|
|||
...googleModels,
|
||||
...bedrockModels,
|
||||
...xAIModels,
|
||||
...qwenModels,
|
||||
// misc.
|
||||
kimi: 131000,
|
||||
// GPT-OSS
|
||||
|
|
@ -289,6 +321,7 @@ export const modelMaxOutputs = {
|
|||
'gpt-5': 128000,
|
||||
'gpt-5-mini': 128000,
|
||||
'gpt-5-nano': 128000,
|
||||
'gpt-5-pro': 128000,
|
||||
'gpt-oss-20b': 131000,
|
||||
'gpt-oss-120b': 131000,
|
||||
system_default: 32000,
|
||||
|
|
@ -299,6 +332,7 @@ const anthropicMaxOutputs = {
|
|||
'claude-3-haiku': 4096,
|
||||
'claude-3-sonnet': 4096,
|
||||
'claude-3-opus': 4096,
|
||||
'claude-haiku-4-5': 64000,
|
||||
'claude-opus-4': 32000,
|
||||
'claude-sonnet-4': 64000,
|
||||
'claude-3.5-sonnet': 8192,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const AnimatedSearchInput = ({
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 py-2 outline-none ring-0 backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-400 focus:outline-none focus:ring-0`}
|
||||
className={`peer relative z-20 w-full rounded-lg bg-surface-secondary px-10 py-2 outline-none backdrop-blur-sm transition-all duration-500 ease-in-out placeholder:text-gray-400 focus:ring-ring`}
|
||||
/>
|
||||
|
||||
{/* Gradient overlay */}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,39 @@ import { Check } from 'lucide-react';
|
|||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className = '', ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
type BaseCheckboxProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||
'aria-label' | 'aria-labelledby'
|
||||
> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export type CheckboxProps =
|
||||
| (BaseCheckboxProps & {
|
||||
'aria-label': string;
|
||||
'aria-labelledby'?: never;
|
||||
})
|
||||
| (BaseCheckboxProps & {
|
||||
'aria-labelledby': string;
|
||||
'aria-label'?: never;
|
||||
});
|
||||
|
||||
const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
|
||||
({ className = '', ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
),
|
||||
);
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import {
|
|||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
|
||||
import AnimatedSearchInput from './AnimatedSearchInput';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { TrashIcon, Spinner } from '~/svgs';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Button } from './Button';
|
||||
|
|
@ -118,6 +118,24 @@ const TableRowComponent = <TData, TValue>({
|
|||
);
|
||||
}
|
||||
|
||||
if (cell.column.id === 'title') {
|
||||
return (
|
||||
<TableHead
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
scope="row"
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
|
@ -156,11 +174,13 @@ const DeleteButton = memo(
|
|||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
ariaLabel,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
ariaLabel: string;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
|
|
@ -171,6 +191,7 @@ const DeleteButton = memo(
|
|||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
|
|
@ -202,6 +223,7 @@ export default function DataTable<TData, TValue>({
|
|||
isLoading,
|
||||
enableSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -359,6 +381,7 @@ export default function DataTable<TData, TValue>({
|
|||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
ariaLabel={localize('com_ui_delete_selected_items')}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
|
||||
|
|
@ -400,6 +423,7 @@ export default function DataTable<TData, TValue>({
|
|||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
scope="col"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -20,6 +21,19 @@ export function DataTableColumnHeader<TData, TValue>({
|
|||
title,
|
||||
className = '',
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getSortIcon = () => {
|
||||
const sortDirection = column.getIsSorted();
|
||||
if (sortDirection === 'desc') {
|
||||
return <ArrowDownIcon className="ml-2 h-4 w-4" />;
|
||||
}
|
||||
if (sortDirection === 'asc') {
|
||||
return <ArrowUpIcon className="ml-2 h-4 w-4" />;
|
||||
}
|
||||
return <CaretSortIcon className="ml-2 h-4 w-4" />;
|
||||
};
|
||||
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
|
@ -28,15 +42,14 @@ export function DataTableColumnHeader<TData, TValue>({
|
|||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
aria-label={localize('com_ui_filter_by', { title })}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
{getSortIcon()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[1001]">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
|||
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||
{showCancelButton && (
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline">{localize('com_ui_cancel')}</Button>
|
||||
<Button variant="outline" aria-label={localize('com_ui_cancel')}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
)}
|
||||
{buttons != null ? buttons : null}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,39 @@ import * as React from 'react';
|
|||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-unchecked',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
type BaseSwitchProps = Omit<
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>,
|
||||
'aria-label' | 'aria-labelledby'
|
||||
>;
|
||||
|
||||
type SwitchProps =
|
||||
| (BaseSwitchProps & {
|
||||
'aria-label': string;
|
||||
'aria-labelledby'?: never;
|
||||
})
|
||||
| (BaseSwitchProps & {
|
||||
'aria-labelledby': string;
|
||||
'aria-label'?: never;
|
||||
});
|
||||
|
||||
const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-unchecked',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
),
|
||||
);
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
|
|
|||
|
|
@ -4,7 +4,19 @@ import ReactTextareaAutosize from 'react-textarea-autosize';
|
|||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
import { chatDirectionAtom } from '~/store';
|
||||
|
||||
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
|
||||
type BaseTextareaAutosizeProps = Omit<TextareaAutosizeProps, 'aria-label' | 'aria-labelledby'>;
|
||||
|
||||
export type TextareaAutosizePropsWithAria =
|
||||
| (BaseTextareaAutosizeProps & {
|
||||
'aria-label': string;
|
||||
'aria-labelledby'?: never;
|
||||
})
|
||||
| (BaseTextareaAutosizeProps & {
|
||||
'aria-labelledby': string;
|
||||
'aria-label'?: never;
|
||||
});
|
||||
|
||||
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizePropsWithAria>(
|
||||
(props, ref) => {
|
||||
const [, setIsRerendered] = useState(false);
|
||||
const chatDirection = useAtomValue(chatDirectionAtom).toLowerCase();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_no_options": "No options available"
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_delete_selected_items": "Delete selected items",
|
||||
"com_ui_filter_by": "Filter by {{title}}",
|
||||
"com_ui_cancel_dialog": "Cancel dialog"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -992,6 +992,8 @@ const sharedOpenAIModels = [
|
|||
const sharedAnthropicModels = [
|
||||
'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-haiku-4-5',
|
||||
'claude-haiku-4-5-20251001',
|
||||
'claude-opus-4-1',
|
||||
'claude-opus-4-1-20250805',
|
||||
'claude-sonnet-4-20250514',
|
||||
|
|
@ -1017,6 +1019,9 @@ const sharedAnthropicModels = [
|
|||
];
|
||||
|
||||
export const bedrockModels = [
|
||||
'anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
'anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
'anthropic.claude-opus-4-1-20250805-v1:0',
|
||||
'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
'anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||
|
|
@ -1568,9 +1573,9 @@ export enum TTSProviders {
|
|||
/** Enum for app-wide constants */
|
||||
export enum Constants {
|
||||
/** Key for the app's version. */
|
||||
VERSION = 'v0.8.0',
|
||||
VERSION = 'v0.8.1-rc1',
|
||||
/** Key for the Custom Config's version (librechat.yaml). */
|
||||
CONFIG_VERSION = '1.3.0',
|
||||
CONFIG_VERSION = '1.3.1',
|
||||
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
||||
NO_PARENT = '00000000-0000-0000-0000-000000000000',
|
||||
/** Standard value to use whatever the submission prelim. `responseMessageId` is */
|
||||
|
|
@ -1603,6 +1608,8 @@ export enum Constants {
|
|||
mcp_prefix = 'mcp_',
|
||||
/** Unique value to indicate all MCP servers. For backend use only. */
|
||||
mcp_all = 'sys__all__sys',
|
||||
/** Unique value to indicate clearing MCP servers from UI state. For frontend use only. */
|
||||
mcp_clear = 'sys__clear__sys',
|
||||
/**
|
||||
* Unique value to indicate the MCP tool was added to an agent.
|
||||
* This helps inform the UI if the mcp server was previously added.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export type TModelSpec = {
|
|||
showIconInHeader?: boolean;
|
||||
iconURL?: string | EModelEndpoint; // Allow using project-included icons
|
||||
authType?: AuthType;
|
||||
webSearch?: boolean;
|
||||
fileSearch?: boolean;
|
||||
executeCode?: boolean;
|
||||
mcpServers?: string[];
|
||||
};
|
||||
|
||||
export const tModelSpecSchema = z.object({
|
||||
|
|
@ -40,6 +44,10 @@ export const tModelSpecSchema = z.object({
|
|||
showIconInHeader: z.boolean().optional(),
|
||||
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
|
||||
authType: authTypeSchema.optional(),
|
||||
webSearch: z.boolean().optional(),
|
||||
fileSearch: z.boolean().optional(),
|
||||
executeCode: z.boolean().optional(),
|
||||
mcpServers: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const specsConfigSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ export const googleSettings = {
|
|||
},
|
||||
thinkingBudget: {
|
||||
min: -1 as const,
|
||||
max: 32768 as const,
|
||||
max: 32000 as const,
|
||||
step: 1 as const,
|
||||
/** `-1` = Dynamic Thinking, meaning the model will adjust
|
||||
* the budget based on the complexity of the request.
|
||||
|
|
@ -349,6 +349,8 @@ export const googleSettings = {
|
|||
};
|
||||
|
||||
const ANTHROPIC_MAX_OUTPUT = 128000 as const;
|
||||
const CLAUDE_4_64K_MAX_OUTPUT = 64000 as const;
|
||||
const CLAUDE_32K_MAX_OUTPUT = 32000 as const;
|
||||
const DEFAULT_MAX_OUTPUT = 8192 as const;
|
||||
const LEGACY_ANTHROPIC_MAX_OUTPUT = 4096 as const;
|
||||
export const anthropicSettings = {
|
||||
|
|
@ -379,18 +381,27 @@ export const anthropicSettings = {
|
|||
step: 1 as const,
|
||||
default: DEFAULT_MAX_OUTPUT,
|
||||
reset: (modelName: string) => {
|
||||
if (/claude-3[-.]5-sonnet/.test(modelName) || /claude-3[-.]7/.test(modelName)) {
|
||||
return DEFAULT_MAX_OUTPUT;
|
||||
if (/claude-(?:sonnet|haiku)[-.]?[4-9]/.test(modelName)) {
|
||||
return CLAUDE_4_64K_MAX_OUTPUT;
|
||||
}
|
||||
|
||||
return 4096;
|
||||
if (/claude-opus[-.]?[4-9]/.test(modelName)) {
|
||||
return CLAUDE_32K_MAX_OUTPUT;
|
||||
}
|
||||
|
||||
return DEFAULT_MAX_OUTPUT;
|
||||
},
|
||||
set: (value: number, modelName: string) => {
|
||||
if (
|
||||
!(/claude-3[-.]5-sonnet/.test(modelName) || /claude-3[-.]7/.test(modelName)) &&
|
||||
value > LEGACY_ANTHROPIC_MAX_OUTPUT
|
||||
) {
|
||||
return LEGACY_ANTHROPIC_MAX_OUTPUT;
|
||||
if (/claude-(?:sonnet|haiku)[-.]?[4-9]/.test(modelName) && value > CLAUDE_4_64K_MAX_OUTPUT) {
|
||||
return CLAUDE_4_64K_MAX_OUTPUT;
|
||||
}
|
||||
|
||||
if (/claude-(?:opus|haiku)[-.]?[4-9]/.test(modelName) && value > CLAUDE_32K_MAX_OUTPUT) {
|
||||
return CLAUDE_32K_MAX_OUTPUT;
|
||||
}
|
||||
|
||||
if (value > ANTHROPIC_MAX_OUTPUT) {
|
||||
return ANTHROPIC_MAX_OUTPUT;
|
||||
}
|
||||
|
||||
return value;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue