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

This commit is contained in:
victorbjorkgren 2025-10-31 13:16:16 +01:00
commit 23ac2556da
193 changed files with 3845 additions and 692 deletions

View file

@ -1,4 +1,4 @@
# v0.8.0 # v0.8.1-rc1
# Base node image # Base node image
FROM node:20-alpine AS node FROM node:20-alpine AS node

View file

@ -1,5 +1,5 @@
# Dockerfile.multi # Dockerfile.multi
# v0.8.0 # v0.8.1-rc1
# Base for all builds # Base for all builds
FROM node:20-alpine AS base-min FROM node:20-alpine AS base-min

View file

@ -2,7 +2,7 @@ const { z } = require('zod');
const axios = require('axios'); const axios = require('axios');
const { Ollama } = require('ollama'); const { Ollama } = require('ollama');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { logAxiosError } = require('@librechat/api'); const { resolveHeaders } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { deriveBaseURL } = require('~/utils'); const { deriveBaseURL } = require('~/utils');
@ -44,6 +44,7 @@ class OllamaClient {
constructor(options = {}) { constructor(options = {}) {
const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434'); const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434');
this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE; this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE;
this.headers = options.headers ?? {};
/** @type {Ollama} */ /** @type {Ollama} */
this.client = new Ollama({ host }); this.client = new Ollama({ host });
} }
@ -51,27 +52,32 @@ class OllamaClient {
/** /**
* Fetches Ollama models from the specified base API path. * Fetches Ollama models from the specified base API path.
* @param {string} baseURL * @param {string} baseURL
* @param {Object} [options] - Optional configuration
* @param {Partial<IUser>} [options.user] - User object for header resolution
* @param {Record<string, string>} [options.headers] - Headers to include in the request
* @returns {Promise<string[]>} The Ollama models. * @returns {Promise<string[]>} The Ollama models.
* @throws {Error} Throws if the Ollama API request fails
*/ */
static async fetchModels(baseURL) { static async fetchModels(baseURL, options = {}) {
let models = [];
if (!baseURL) { if (!baseURL) {
return models;
}
try {
const ollamaEndpoint = deriveBaseURL(baseURL);
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
timeout: 5000,
});
models = response.data.models.map((tag) => tag.name);
return models;
} catch (error) {
const logMessage =
"Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn't start with `ollama` (case-insensitive).";
logAxiosError({ message: logMessage, error });
return []; return [];
} }
const ollamaEndpoint = deriveBaseURL(baseURL);
const resolvedHeaders = resolveHeaders({
headers: options.headers,
user: options.user,
});
/** @type {Promise<AxiosResponse<OllamaListResponse>>} */
const response = await axios.get(`${ollamaEndpoint}/api/tags`, {
headers: resolvedHeaders,
timeout: 5000,
});
const models = response.data.models.map((tag) => tag.name);
return models;
} }
/** /**

View file

@ -5,6 +5,7 @@ const FormData = require('form-data');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, oaiToolkit } = require('@librechat/api'); const { logAxiosError, oaiToolkit } = require('@librechat/api');
const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
@ -348,16 +349,7 @@ Error Message: ${error.message}`);
}; };
if (process.env.PROXY) { if (process.env.PROXY) {
try { axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
const url = new URL(process.env.PROXY);
axiosConfig.proxy = {
host: url.hostname.replace(/^\[|\]$/g, ''),
port: url.port ? parseInt(url.port, 10) : undefined,
protocol: url.protocol.replace(':', ''),
};
} catch (error) {
logger.error('Error parsing proxy URL:', error);
}
} }
if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) {

View file

@ -62,25 +62,37 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l
* *
* @param {Object} params * @param {Object} params
* @param {ServerRequest} params.req * @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id * @param {string} params.agent_id
* @param {string} params.endpoint * @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found. * @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/ */
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => { const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m; const { model, ...model_parameters } = _m;
const modelSpecs = req.config?.modelSpecs?.list;
/** @type {TModelSpec | null} */
let modelSpec = null;
if (spec != null && spec !== '') {
modelSpec = modelSpecs?.find((s) => s.name === spec) || null;
}
/** @type {TEphemeralAgent | null} */ /** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent; const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp); const mcpServers = new Set(ephemeralAgent?.mcp);
if (modelSpec?.mcpServers) {
for (const mcpServer of modelSpec.mcpServers) {
mcpServers.add(mcpServer);
}
}
/** @type {string[]} */ /** @type {string[]} */
const tools = []; const tools = [];
if (ephemeralAgent?.execute_code === true) { if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) {
tools.push(Tools.execute_code); tools.push(Tools.execute_code);
} }
if (ephemeralAgent?.file_search === true) { if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) {
tools.push(Tools.file_search); tools.push(Tools.file_search);
} }
if (ephemeralAgent?.web_search === true) { if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) {
tools.push(Tools.web_search); tools.push(Tools.web_search);
} }
@ -122,17 +134,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
* *
* @param {Object} params * @param {Object} params
* @param {ServerRequest} params.req * @param {ServerRequest} params.req
* @param {string} params.spec
* @param {string} params.agent_id * @param {string} params.agent_id
* @param {string} params.endpoint * @param {string} params.endpoint
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found. * @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
*/ */
const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => { const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => {
if (!agent_id) { if (!agent_id) {
return null; return null;
} }
if (agent_id === EPHEMERAL_AGENT_ID) { if (agent_id === EPHEMERAL_AGENT_ID) {
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters }); return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters });
} }
const agent = await getAgent({ const agent = await getAgent({
id: agent_id, id: agent_id,

View file

@ -1,4 +1,4 @@
const { matchModelName } = require('@librechat/api'); const { matchModelName, findMatchingPattern } = require('@librechat/api');
const defaultRate = 6; const defaultRate = 6;
/** /**
@ -6,44 +6,58 @@ const defaultRate = 6;
* source: https://aws.amazon.com/bedrock/pricing/ * source: https://aws.amazon.com/bedrock/pricing/
* */ * */
const bedrockValues = { const bedrockValues = {
// Basic llama2 patterns // Basic llama2 patterns (base defaults to smallest variant)
llama2: { prompt: 0.75, completion: 1.0 },
'llama-2': { prompt: 0.75, completion: 1.0 },
'llama2-13b': { prompt: 0.75, completion: 1.0 }, 'llama2-13b': { prompt: 0.75, completion: 1.0 },
'llama2:13b': { prompt: 0.75, completion: 1.0 },
'llama2:70b': { prompt: 1.95, completion: 2.56 }, 'llama2:70b': { prompt: 1.95, completion: 2.56 },
'llama2-70b': { prompt: 1.95, completion: 2.56 }, 'llama2-70b': { prompt: 1.95, completion: 2.56 },
// Basic llama3 patterns // Basic llama3 patterns (base defaults to smallest variant)
llama3: { prompt: 0.3, completion: 0.6 },
'llama-3': { prompt: 0.3, completion: 0.6 },
'llama3-8b': { prompt: 0.3, completion: 0.6 }, 'llama3-8b': { prompt: 0.3, completion: 0.6 },
'llama3:8b': { prompt: 0.3, completion: 0.6 }, 'llama3:8b': { prompt: 0.3, completion: 0.6 },
'llama3-70b': { prompt: 2.65, completion: 3.5 }, 'llama3-70b': { prompt: 2.65, completion: 3.5 },
'llama3:70b': { prompt: 2.65, completion: 3.5 }, 'llama3:70b': { prompt: 2.65, completion: 3.5 },
// llama3-x-Nb pattern // llama3-x-Nb pattern (base defaults to smallest variant)
'llama3-1': { prompt: 0.22, completion: 0.22 },
'llama3-1-8b': { prompt: 0.22, completion: 0.22 }, 'llama3-1-8b': { prompt: 0.22, completion: 0.22 },
'llama3-1-70b': { prompt: 0.72, completion: 0.72 }, 'llama3-1-70b': { prompt: 0.72, completion: 0.72 },
'llama3-1-405b': { prompt: 2.4, completion: 2.4 }, 'llama3-1-405b': { prompt: 2.4, completion: 2.4 },
'llama3-2': { prompt: 0.1, completion: 0.1 },
'llama3-2-1b': { prompt: 0.1, completion: 0.1 }, 'llama3-2-1b': { prompt: 0.1, completion: 0.1 },
'llama3-2-3b': { prompt: 0.15, completion: 0.15 }, 'llama3-2-3b': { prompt: 0.15, completion: 0.15 },
'llama3-2-11b': { prompt: 0.16, completion: 0.16 }, 'llama3-2-11b': { prompt: 0.16, completion: 0.16 },
'llama3-2-90b': { prompt: 0.72, completion: 0.72 }, 'llama3-2-90b': { prompt: 0.72, completion: 0.72 },
'llama3-3': { prompt: 2.65, completion: 3.5 },
'llama3-3-70b': { prompt: 2.65, completion: 3.5 },
// llama3.x:Nb pattern // llama3.x:Nb pattern (base defaults to smallest variant)
'llama3.1': { prompt: 0.22, completion: 0.22 },
'llama3.1:8b': { prompt: 0.22, completion: 0.22 }, 'llama3.1:8b': { prompt: 0.22, completion: 0.22 },
'llama3.1:70b': { prompt: 0.72, completion: 0.72 }, 'llama3.1:70b': { prompt: 0.72, completion: 0.72 },
'llama3.1:405b': { prompt: 2.4, completion: 2.4 }, 'llama3.1:405b': { prompt: 2.4, completion: 2.4 },
'llama3.2': { prompt: 0.1, completion: 0.1 },
'llama3.2:1b': { prompt: 0.1, completion: 0.1 }, 'llama3.2:1b': { prompt: 0.1, completion: 0.1 },
'llama3.2:3b': { prompt: 0.15, completion: 0.15 }, 'llama3.2:3b': { prompt: 0.15, completion: 0.15 },
'llama3.2:11b': { prompt: 0.16, completion: 0.16 }, 'llama3.2:11b': { prompt: 0.16, completion: 0.16 },
'llama3.2:90b': { prompt: 0.72, completion: 0.72 }, 'llama3.2:90b': { prompt: 0.72, completion: 0.72 },
'llama3.3': { prompt: 2.65, completion: 3.5 },
'llama3.3:70b': { prompt: 2.65, completion: 3.5 },
// llama-3.x-Nb pattern // llama-3.x-Nb pattern (base defaults to smallest variant)
'llama-3.1': { prompt: 0.22, completion: 0.22 },
'llama-3.1-8b': { prompt: 0.22, completion: 0.22 }, 'llama-3.1-8b': { prompt: 0.22, completion: 0.22 },
'llama-3.1-70b': { prompt: 0.72, completion: 0.72 }, 'llama-3.1-70b': { prompt: 0.72, completion: 0.72 },
'llama-3.1-405b': { prompt: 2.4, completion: 2.4 }, 'llama-3.1-405b': { prompt: 2.4, completion: 2.4 },
'llama-3.2': { prompt: 0.1, completion: 0.1 },
'llama-3.2-1b': { prompt: 0.1, completion: 0.1 }, 'llama-3.2-1b': { prompt: 0.1, completion: 0.1 },
'llama-3.2-3b': { prompt: 0.15, completion: 0.15 }, 'llama-3.2-3b': { prompt: 0.15, completion: 0.15 },
'llama-3.2-11b': { prompt: 0.16, completion: 0.16 }, 'llama-3.2-11b': { prompt: 0.16, completion: 0.16 },
'llama-3.2-90b': { prompt: 0.72, completion: 0.72 }, 'llama-3.2-90b': { prompt: 0.72, completion: 0.72 },
'llama-3.3': { prompt: 2.65, completion: 3.5 },
'llama-3.3-70b': { prompt: 2.65, completion: 3.5 }, 'llama-3.3-70b': { prompt: 2.65, completion: 3.5 },
'mistral-7b': { prompt: 0.15, completion: 0.2 }, 'mistral-7b': { prompt: 0.15, completion: 0.2 },
'mistral-small': { prompt: 0.15, completion: 0.2 }, 'mistral-small': { prompt: 0.15, completion: 0.2 },
@ -52,15 +66,19 @@ const bedrockValues = {
'mistral-large-2407': { prompt: 3.0, completion: 9.0 }, 'mistral-large-2407': { prompt: 3.0, completion: 9.0 },
'command-text': { prompt: 1.5, completion: 2.0 }, 'command-text': { prompt: 1.5, completion: 2.0 },
'command-light': { prompt: 0.3, completion: 0.6 }, 'command-light': { prompt: 0.3, completion: 0.6 },
'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 }, // AI21 models
'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 }, 'j2-mid': { prompt: 12.5, completion: 12.5 },
'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 }, 'j2-ultra': { prompt: 18.8, completion: 18.8 },
'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 }, 'jamba-instruct': { prompt: 0.5, completion: 0.7 },
'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 }, // Amazon Titan models
'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 }, 'titan-text-lite': { prompt: 0.15, completion: 0.2 },
'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 }, 'titan-text-express': { prompt: 0.2, completion: 0.6 },
'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 }, 'titan-text-premier': { prompt: 0.5, completion: 1.5 },
'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 }, // Amazon Nova models
'nova-micro': { prompt: 0.035, completion: 0.14 },
'nova-lite': { prompt: 0.06, completion: 0.24 },
'nova-pro': { prompt: 0.8, completion: 3.2 },
'nova-premier': { prompt: 2.5, completion: 12.5 },
'deepseek.r1': { prompt: 1.35, completion: 5.4 }, 'deepseek.r1': { prompt: 1.35, completion: 5.4 },
}; };
@ -71,100 +89,136 @@ const bedrockValues = {
*/ */
const tokenValues = Object.assign( const tokenValues = Object.assign(
{ {
// Legacy token size mappings (generic patterns - check LAST)
'8k': { prompt: 30, completion: 60 }, '8k': { prompt: 30, completion: 60 },
'32k': { prompt: 60, completion: 120 }, '32k': { prompt: 60, completion: 120 },
'4k': { prompt: 1.5, completion: 2 }, '4k': { prompt: 1.5, completion: 2 },
'16k': { prompt: 3, completion: 4 }, '16k': { prompt: 3, completion: 4 },
// Generic fallback patterns (check LAST)
'claude-': { prompt: 0.8, completion: 2.4 },
deepseek: { prompt: 0.28, completion: 0.42 },
command: { prompt: 0.38, completion: 0.38 },
gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
gemini: { prompt: 0.5, completion: 1.5 },
'gpt-oss': { prompt: 0.05, completion: 0.2 },
// Specific model variants (check FIRST - more specific patterns at end)
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 }, 'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'o4-mini': { prompt: 1.1, completion: 4.4 }, 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'o3-mini': { prompt: 1.1, completion: 4.4 }, 'gpt-4-1106': { prompt: 10, completion: 30 },
o3: { prompt: 2, completion: 8 }, 'gpt-4.1': { prompt: 2, completion: 8 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o1: { prompt: 15, completion: 60 },
'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 }, 'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 },
'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 }, 'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 },
'gpt-4.1': { prompt: 2, completion: 8 },
'gpt-4.5': { prompt: 75, completion: 150 }, 'gpt-4.5': { prompt: 75, completion: 150 },
'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-5': { prompt: 1.25, completion: 10 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-4o': { prompt: 2.5, completion: 10 }, 'gpt-4o': { prompt: 2.5, completion: 10 },
'gpt-4o-2024-05-13': { prompt: 5, completion: 15 }, 'gpt-4o-2024-05-13': { prompt: 5, completion: 15 },
'gpt-4-1106': { prompt: 10, completion: 30 }, 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, 'gpt-5': { prompt: 1.25, completion: 10 },
'claude-3-opus': { prompt: 15, completion: 75 }, 'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
'gpt-5-mini': { prompt: 0.25, completion: 2 },
'gpt-5-pro': { prompt: 15, completion: 120 },
o1: { prompt: 15, completion: 60 },
'o1-mini': { prompt: 1.1, completion: 4.4 },
'o1-preview': { prompt: 15, completion: 60 },
o3: { prompt: 2, completion: 8 },
'o3-mini': { prompt: 1.1, completion: 4.4 },
'o4-mini': { prompt: 1.1, completion: 4.4 },
'claude-instant': { prompt: 0.8, completion: 2.4 },
'claude-2': { prompt: 8, completion: 24 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-3-sonnet': { prompt: 3, completion: 15 }, 'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-5-haiku': { prompt: 0.8, completion: 4 },
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
'claude-3-5-sonnet': { prompt: 3, completion: 15 }, 'claude-3-5-sonnet': { prompt: 3, completion: 15 },
'claude-3.5-sonnet': { prompt: 3, completion: 15 }, 'claude-3.5-sonnet': { prompt: 3, completion: 15 },
'claude-3-7-sonnet': { prompt: 3, completion: 15 }, 'claude-3-7-sonnet': { prompt: 3, completion: 15 },
'claude-3.7-sonnet': { prompt: 3, completion: 15 }, 'claude-3.7-sonnet': { prompt: 3, completion: 15 },
'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, 'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-3.5-haiku': { prompt: 0.8, completion: 4 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-sonnet-4': { prompt: 3, completion: 15 },
'claude-opus-4': { prompt: 15, completion: 75 }, 'claude-opus-4': { prompt: 15, completion: 75 },
'claude-2.1': { prompt: 8, completion: 24 }, 'claude-sonnet-4': { prompt: 3, completion: 15 },
'claude-2': { prompt: 8, completion: 24 },
'claude-instant': { prompt: 0.8, completion: 2.4 },
'claude-': { prompt: 0.8, completion: 2.4 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 }, 'command-r': { prompt: 0.5, completion: 1.5 },
'command-r-plus': { prompt: 3, completion: 15 },
'command-text': { prompt: 1.5, completion: 2.0 },
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 }, 'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
deepseek: { prompt: 0.28, completion: 0.42 }, 'deepseek-r1': { prompt: 0.4, completion: 2.0 },
/* cohere doesn't have rates for the older command models, 'deepseek-v3': { prompt: 0.2, completion: 0.8 },
so this was from https://artificialanalysis.ai/models/command-light/providers */ 'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing)
command: { prompt: 0.38, completion: 0.38 }, 'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing)
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemma-3-27b': { prompt: 0.09, completion: 0.16 },
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-1.5': { prompt: 2.5, completion: 10 },
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, 'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing)
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 }, 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing)
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time 'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
'gemini-1.5': { prompt: 2.5, completion: 10 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
gemini: { prompt: 0.5, completion: 1.5 }, grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, 'grok-beta': { prompt: 5.0, completion: 15.0 },
'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
'grok-2-vision': { prompt: 2.0, completion: 10.0 },
'grok-vision-beta': { prompt: 5.0, completion: 15.0 }, 'grok-vision-beta': { prompt: 5.0, completion: 15.0 },
'grok-2': { prompt: 2.0, completion: 10.0 },
'grok-2-1212': { prompt: 2.0, completion: 10.0 }, 'grok-2-1212': { prompt: 2.0, completion: 10.0 },
'grok-2-latest': { prompt: 2.0, completion: 10.0 }, 'grok-2-latest': { prompt: 2.0, completion: 10.0 },
'grok-2': { prompt: 2.0, completion: 10.0 }, 'grok-2-vision': { prompt: 2.0, completion: 10.0 },
'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 },
'grok-3-mini': { prompt: 0.3, completion: 0.5 }, 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 },
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
'grok-3': { prompt: 3.0, completion: 15.0 }, 'grok-3': { prompt: 3.0, completion: 15.0 },
'grok-3-fast': { prompt: 5.0, completion: 25.0 },
'grok-3-mini': { prompt: 0.3, completion: 0.5 },
'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
'grok-4': { prompt: 3.0, completion: 15.0 }, 'grok-4': { prompt: 3.0, completion: 15.0 },
'grok-beta': { prompt: 5.0, completion: 15.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'pixtral-large': { prompt: 2.0, completion: 6.0 },
'mistral-saba': { prompt: 0.2, completion: 0.6 },
codestral: { prompt: 0.3, completion: 0.9 }, codestral: { prompt: 0.3, completion: 0.9 },
'ministral-8b': { prompt: 0.1, completion: 0.1 },
'ministral-3b': { prompt: 0.04, completion: 0.04 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 },
// GPT-OSS models 'ministral-8b': { prompt: 0.1, completion: 0.1 },
'gpt-oss': { prompt: 0.05, completion: 0.2 }, 'mistral-nemo': { prompt: 0.15, completion: 0.15 },
'mistral-saba': { prompt: 0.2, completion: 0.6 },
'pixtral-large': { prompt: 2.0, completion: 6.0 },
'mistral-large': { prompt: 2.0, completion: 6.0 },
'mixtral-8x22b': { prompt: 0.65, completion: 0.65 },
kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing)
// GPT-OSS models (specific sizes)
'gpt-oss:20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss:20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss-20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 },
'gpt-oss:120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 },
'gpt-oss-120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 },
// GLM models (Zhipu AI) // GLM models (Zhipu AI) - general to specific
glm4: { prompt: 0.1, completion: 0.1 }, glm4: { prompt: 0.1, completion: 0.1 },
'glm-4': { prompt: 0.1, completion: 0.1 }, 'glm-4': { prompt: 0.1, completion: 0.1 },
'glm-4-32b': { prompt: 0.1, completion: 0.1 }, 'glm-4-32b': { prompt: 0.1, completion: 0.1 },
'glm-4.5': { prompt: 0.35, completion: 1.55 }, 'glm-4.5': { prompt: 0.35, completion: 1.55 },
'glm-4.5v': { prompt: 0.6, completion: 1.8 },
'glm-4.5-air': { prompt: 0.14, completion: 0.86 }, 'glm-4.5-air': { prompt: 0.14, completion: 0.86 },
'glm-4.5v': { prompt: 0.6, completion: 1.8 },
'glm-4.6': { prompt: 0.5, completion: 1.75 }, 'glm-4.6': { prompt: 0.5, completion: 1.75 },
// Qwen models
qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing)
'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern
'qwen-turbo': { prompt: 0.05, completion: 0.2 },
'qwen-plus': { prompt: 0.4, completion: 1.2 },
'qwen-max': { prompt: 1.6, completion: 6.4 },
'qwq-32b': { prompt: 0.15, completion: 0.4 },
// Qwen3 models
qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing)
'qwen3-8b': { prompt: 0.035, completion: 0.138 },
'qwen3-14b': { prompt: 0.05, completion: 0.22 },
'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 },
'qwen3-32b': { prompt: 0.05, completion: 0.2 },
'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 },
// Qwen3 VL (Vision-Language) models
'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 },
'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 },
'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 },
'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 },
// Qwen3 specialized models
'qwen3-max': { prompt: 1.2, completion: 6 },
'qwen3-coder': { prompt: 0.22, completion: 0.95 },
'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 },
'qwen3-coder-plus': { prompt: 1, completion: 5 },
'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 },
'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 },
}, },
bedrockValues, bedrockValues,
); );
@ -195,67 +249,39 @@ const cacheTokenValues = {
* @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found. * @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found.
*/ */
const getValueKey = (model, endpoint) => { const getValueKey = (model, endpoint) => {
if (!model || typeof model !== 'string') {
return undefined;
}
// Use findMatchingPattern directly against tokenValues for efficient lookup
if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) {
const matchedKey = findMatchingPattern(model, tokenValues);
if (matchedKey) {
return matchedKey;
}
}
// Fallback: use matchModelName for edge cases and legacy handling
const modelName = matchModelName(model, endpoint); const modelName = matchModelName(model, endpoint);
if (!modelName) { if (!modelName) {
return undefined; return undefined;
} }
// Legacy token size mappings and aliases for older models
if (modelName.includes('gpt-3.5-turbo-16k')) { if (modelName.includes('gpt-3.5-turbo-16k')) {
return '16k'; return '16k';
} else if (modelName.includes('gpt-3.5-turbo-0125')) {
return 'gpt-3.5-turbo-0125';
} else if (modelName.includes('gpt-3.5-turbo-1106')) {
return 'gpt-3.5-turbo-1106';
} else if (modelName.includes('gpt-3.5')) { } else if (modelName.includes('gpt-3.5')) {
return '4k'; return '4k';
} else if (modelName.includes('o4-mini')) {
return 'o4-mini';
} else if (modelName.includes('o4')) {
return 'o4';
} else if (modelName.includes('o3-mini')) {
return 'o3-mini';
} else if (modelName.includes('o3')) {
return 'o3';
} else if (modelName.includes('o1-preview')) {
return 'o1-preview';
} else if (modelName.includes('o1-mini')) {
return 'o1-mini';
} else if (modelName.includes('o1')) {
return 'o1';
} else if (modelName.includes('gpt-4.5')) {
return 'gpt-4.5';
} else if (modelName.includes('gpt-4.1-nano')) {
return 'gpt-4.1-nano';
} else if (modelName.includes('gpt-4.1-mini')) {
return 'gpt-4.1-mini';
} else if (modelName.includes('gpt-4.1')) {
return 'gpt-4.1';
} else if (modelName.includes('gpt-4o-2024-05-13')) {
return 'gpt-4o-2024-05-13';
} else if (modelName.includes('gpt-5-nano')) {
return 'gpt-5-nano';
} else if (modelName.includes('gpt-5-mini')) {
return 'gpt-5-mini';
} else if (modelName.includes('gpt-5')) {
return 'gpt-5';
} else if (modelName.includes('gpt-4o-mini')) {
return 'gpt-4o-mini';
} else if (modelName.includes('gpt-4o')) {
return 'gpt-4o';
} else if (modelName.includes('gpt-4-vision')) { } else if (modelName.includes('gpt-4-vision')) {
return 'gpt-4-1106'; return 'gpt-4-1106'; // Alias for gpt-4-vision
} else if (modelName.includes('gpt-4-1106')) {
return 'gpt-4-1106';
} else if (modelName.includes('gpt-4-0125')) { } else if (modelName.includes('gpt-4-0125')) {
return 'gpt-4-1106'; return 'gpt-4-1106'; // Alias for gpt-4-0125
} else if (modelName.includes('gpt-4-turbo')) { } else if (modelName.includes('gpt-4-turbo')) {
return 'gpt-4-1106'; return 'gpt-4-1106'; // Alias for gpt-4-turbo
} else if (modelName.includes('gpt-4-32k')) { } else if (modelName.includes('gpt-4-32k')) {
return '32k'; return '32k';
} else if (modelName.includes('gpt-4')) { } else if (modelName.includes('gpt-4')) {
return '8k'; return '8k';
} else if (tokenValues[modelName]) {
return modelName;
} }
return undefined; return undefined;

View file

@ -1,3 +1,4 @@
const { maxTokensMap } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider'); const { EModelEndpoint } = require('librechat-data-provider');
const { const {
defaultRate, defaultRate,
@ -113,6 +114,14 @@ describe('getValueKey', () => {
expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano'); expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano');
}); });
it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => {
expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro');
});
it('should return "gpt-4o" for model type of "gpt-4o"', () => { it('should return "gpt-4o" for model type of "gpt-4o"', () => {
expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o'); expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o');
expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o'); expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o');
@ -288,6 +297,20 @@ describe('getMultiplier', () => {
); );
}); });
it('should return the correct multiplier for gpt-5-pro', () => {
const valueKey = getValueKey('gpt-5-pro-2025-01-30');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt);
expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe(
tokenValues['gpt-5-pro'].completion,
);
expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe(
tokenValues['gpt-5-pro'].prompt,
);
expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe(
tokenValues['gpt-5-pro'].completion,
);
});
it('should return the correct multiplier for gpt-4o', () => { it('should return the correct multiplier for gpt-4o', () => {
const valueKey = getValueKey('gpt-4o-2024-08-06'); const valueKey = getValueKey('gpt-4o-2024-08-06');
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt); expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
@ -471,6 +494,249 @@ describe('AWS Bedrock Model Tests', () => {
}); });
}); });
describe('Amazon Model Tests', () => {
describe('Amazon Nova Models', () => {
it('should return correct pricing for nova-premier', () => {
expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe(
tokenValues['nova-premier'].prompt,
);
expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe(
tokenValues['nova-premier'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-premier'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-premier'].completion,
);
});
it('should return correct pricing for nova-pro', () => {
expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe(
tokenValues['nova-pro'].prompt,
);
expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe(
tokenValues['nova-pro'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-pro'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-pro'].completion,
);
});
it('should return correct pricing for nova-lite', () => {
expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe(
tokenValues['nova-lite'].prompt,
);
expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe(
tokenValues['nova-lite'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-lite'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-lite'].completion,
);
});
it('should return correct pricing for nova-micro', () => {
expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe(
tokenValues['nova-micro'].prompt,
);
expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe(
tokenValues['nova-micro'].completion,
);
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['nova-micro'].prompt,
);
expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe(
tokenValues['nova-micro'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier'];
const fullModels = [
'amazon.nova-micro-v1:0',
'amazon.nova-lite-v1:0',
'amazon.nova-pro-v1:0',
'amazon.nova-premier-v1:0',
];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
describe('Amazon Titan Models', () => {
it('should return correct pricing for titan-text-premier', () => {
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-premier'].prompt,
);
expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe(
tokenValues['titan-text-premier'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-premier'].prompt,
);
expect(
getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }),
).toBe(tokenValues['titan-text-premier'].completion);
});
it('should return correct pricing for titan-text-express', () => {
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-express'].prompt,
);
expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe(
tokenValues['titan-text-express'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-express'].prompt,
);
expect(
getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }),
).toBe(tokenValues['titan-text-express'].completion);
});
it('should return correct pricing for titan-text-lite', () => {
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-lite'].prompt,
);
expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe(
tokenValues['titan-text-lite'].completion,
);
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe(
tokenValues['titan-text-lite'].prompt,
);
expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe(
tokenValues['titan-text-lite'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier'];
const fullModels = [
'amazon.titan-text-lite-v1',
'amazon.titan-text-express-v1',
'amazon.titan-text-premier-v1:0',
];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
});
describe('AI21 Model Tests', () => {
describe('AI21 J2 Models', () => {
it('should return correct pricing for j2-mid', () => {
expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe(
tokenValues['j2-mid'].prompt,
);
expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe(
tokenValues['j2-mid'].completion,
);
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe(
tokenValues['j2-mid'].prompt,
);
expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe(
tokenValues['j2-mid'].completion,
);
});
it('should return correct pricing for j2-ultra', () => {
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe(
tokenValues['j2-ultra'].prompt,
);
expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe(
tokenValues['j2-ultra'].completion,
);
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe(
tokenValues['j2-ultra'].prompt,
);
expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe(
tokenValues['j2-ultra'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const models = ['j2-mid', 'j2-ultra'];
const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1'];
models.forEach((shortModel, i) => {
const fullModel = fullModels[i];
const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' });
const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' });
const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' });
const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' });
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues[shortModel].prompt);
expect(shortCompletion).toBe(tokenValues[shortModel].completion);
});
});
});
describe('AI21 Jamba Models', () => {
it('should return correct pricing for jamba-instruct', () => {
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe(
tokenValues['jamba-instruct'].prompt,
);
expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe(
tokenValues['jamba-instruct'].completion,
);
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe(
tokenValues['jamba-instruct'].prompt,
);
expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe(
tokenValues['jamba-instruct'].completion,
);
});
it('should match both short and full model names to the same pricing', () => {
const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' });
const fullPrompt = getMultiplier({
model: 'ai21.jamba-instruct-v1:0',
tokenType: 'prompt',
});
const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' });
const fullCompletion = getMultiplier({
model: 'ai21.jamba-instruct-v1:0',
tokenType: 'completion',
});
expect(shortPrompt).toBe(fullPrompt);
expect(shortCompletion).toBe(fullCompletion);
expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt);
expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion);
});
});
});
describe('Deepseek Model Tests', () => { describe('Deepseek Model Tests', () => {
const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1']; const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1'];
@ -502,6 +768,187 @@ describe('Deepseek Model Tests', () => {
}); });
}); });
describe('Qwen3 Model Tests', () => {
describe('Qwen3 Base Models', () => {
it('should return correct pricing for qwen3 base pattern', () => {
expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe(
tokenValues['qwen3'].prompt,
);
expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => {
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3'].prompt,
);
expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
it('should return correct pricing for qwen3-8b', () => {
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-8b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-8b'].completion,
);
});
it('should return correct pricing for qwen3-14b', () => {
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-14b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-14b'].completion,
);
});
it('should return correct pricing for qwen3-235b-a22b', () => {
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-235b-a22b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-235b-a22b'].completion,
);
});
it('should handle model name variations with provider prefixes', () => {
const models = [
{ input: 'qwen3', expected: 'qwen3' },
{ input: 'qwen3-4b', expected: 'qwen3' },
{ input: 'qwen3-8b', expected: 'qwen3-8b' },
{ input: 'qwen3-32b', expected: 'qwen3-32b' },
];
models.forEach(({ input, expected }) => {
const withPrefix = `alibaba/${input}`;
expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe(
tokenValues[expected].prompt,
);
expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe(
tokenValues[expected].completion,
);
});
});
});
describe('Qwen3 VL (Vision-Language) Models', () => {
it('should return correct pricing for qwen3-vl-8b-thinking', () => {
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-8b-thinking'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-8b-thinking'].completion,
);
});
it('should return correct pricing for qwen3-vl-8b-instruct', () => {
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-8b-instruct'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-8b-instruct'].completion,
);
});
it('should return correct pricing for qwen3-vl-30b-a3b', () => {
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-30b-a3b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-30b-a3b'].completion,
);
});
it('should return correct pricing for qwen3-vl-235b-a22b', () => {
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-vl-235b-a22b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-vl-235b-a22b'].completion,
);
});
});
describe('Qwen3 Specialized Models', () => {
it('should return correct pricing for qwen3-max', () => {
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-max'].prompt,
);
expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe(
tokenValues['qwen3-max'].completion,
);
});
it('should return correct pricing for qwen3-coder', () => {
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder'].completion,
);
});
it('should return correct pricing for qwen3-coder-plus', () => {
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder-plus'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder-plus'].completion,
);
});
it('should return correct pricing for qwen3-coder-flash', () => {
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-coder-flash'].prompt,
);
expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe(
tokenValues['qwen3-coder-flash'].completion,
);
});
it('should return correct pricing for qwen3-next-80b-a3b', () => {
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe(
tokenValues['qwen3-next-80b-a3b'].prompt,
);
expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe(
tokenValues['qwen3-next-80b-a3b'].completion,
);
});
});
describe('Qwen3 Model Variations', () => {
it('should handle all qwen3 models with provider prefixes', () => {
const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct'];
const prefixes = ['alibaba', 'qwen', 'openrouter'];
models.forEach((model) => {
prefixes.forEach((prefix) => {
const fullModel = `${prefix}/${model}`;
expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe(
tokenValues[model].prompt,
);
expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe(
tokenValues[model].completion,
);
});
});
});
it('should handle qwen3-4b falling back to qwen3 base pattern', () => {
const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview'];
testCases.forEach((model) => {
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['qwen3'].completion,
);
});
});
});
});
describe('getCacheMultiplier', () => { describe('getCacheMultiplier', () => {
it('should return the correct cache multiplier for a given valueKey and cacheType', () => { it('should return the correct cache multiplier for a given valueKey and cacheType', () => {
expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe( expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe(
@ -914,6 +1361,37 @@ describe('Claude Model Tests', () => {
); );
}); });
it('should return correct prompt and completion rates for Claude Haiku 4.5', () => {
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe(
tokenValues['claude-haiku-4-5'].prompt,
);
expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe(
tokenValues['claude-haiku-4-5'].completion,
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-haiku-4-5');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-haiku-4-5'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-haiku-4-5'].completion,
);
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [ const modelVariations = [
'claude-sonnet-4', 'claude-sonnet-4',
@ -991,3 +1469,119 @@ describe('Claude Model Tests', () => {
}); });
}); });
}); });
describe('tokens.ts and tx.js sync validation', () => {
it('should resolve all models in maxTokensMap to pricing via getValueKey', () => {
const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]);
const txKeys = Object.keys(tokenValues);
const unresolved = [];
tokensKeys.forEach((key) => {
// Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k')
if (/^\d+k$/.test(key)) return;
// Skip generic pattern keys (end with '-' or ':')
if (key.endsWith('-') || key.endsWith(':')) return;
// Try to resolve via getValueKey
const resolvedKey = getValueKey(key);
// If it resolves and the resolved key has pricing, success
if (resolvedKey && txKeys.includes(resolvedKey)) return;
// If it resolves to a legacy key (4k, 8k, etc), also OK
if (resolvedKey && /^\d+k$/.test(resolvedKey)) return;
// If we get here, this model can't get pricing - flag it
unresolved.push({
key,
resolvedKey: resolvedKey || 'undefined',
context: maxTokensMap[EModelEndpoint.openAI][key],
});
});
if (unresolved.length > 0) {
console.log('\nModels that cannot resolve to pricing via getValueKey:');
unresolved.forEach(({ key, resolvedKey, context }) => {
console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`);
});
}
expect(unresolved).toEqual([]);
});
it('should not have redundant dated variants with same pricing and context as base model', () => {
const txKeys = Object.keys(tokenValues);
const redundant = [];
txKeys.forEach((key) => {
// Check if this is a dated variant (ends with -YYYY-MM-DD)
if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) {
const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, '');
if (txKeys.includes(baseKey)) {
const variantPricing = tokenValues[key];
const basePricing = tokenValues[baseKey];
const variantContext = maxTokensMap[EModelEndpoint.openAI][key];
const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey];
const samePricing =
variantPricing.prompt === basePricing.prompt &&
variantPricing.completion === basePricing.completion;
const sameContext = variantContext === baseContext;
if (samePricing && sameContext) {
redundant.push({
key,
baseKey,
pricing: `${variantPricing.prompt}/${variantPricing.completion}`,
context: variantContext,
});
}
}
}
});
if (redundant.length > 0) {
console.log('\nRedundant dated variants found (same pricing and context as base):');
redundant.forEach(({ key, baseKey, pricing, context }) => {
console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`);
console.log(` Can be removed - pattern matching will handle it`);
});
}
expect(redundant).toEqual([]);
});
it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => {
const txKeys = Object.keys(tokenValues);
const missingContext = [];
txKeys.forEach((key) => {
// Skip legacy token size mappings (4k, 8k, 16k, 32k)
if (/^\d+k$/.test(key)) return;
// Check if this model has a context window defined
const context = maxTokensMap[EModelEndpoint.openAI][key];
if (!context) {
const pricing = tokenValues[key];
missingContext.push({
key,
pricing: `${pricing.prompt}/${pricing.completion}`,
});
}
});
if (missingContext.length > 0) {
console.log('\nModels with pricing but missing context in tokens.ts:');
missingContext.forEach(({ key, pricing }) => {
console.log(` - '${key}' (pricing: ${pricing})`);
console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`);
});
}
expect(missingContext).toEqual([]);
});
});

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/backend", "name": "@librechat/backend",
"version": "v0.8.0", "version": "v0.8.1-rc1",
"description": "", "description": "",
"scripts": { "scripts": {
"start": "echo 'please run this from the root directory'", "start": "echo 'please run this from the root directory'",
@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13", "@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.85", "@librechat/agents": "^2.4.90",
"@librechat/api": "*", "@librechat/api": "*",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",

View file

@ -116,11 +116,15 @@ const refreshController = async (req, res) => {
const token = await setAuthTokens(userId, res, session); const token = await setAuthTokens(userId, res, session);
// trigger OAuth MCP server reconnection asynchronously (best effort) // trigger OAuth MCP server reconnection asynchronously (best effort)
void getOAuthReconnectionManager() try {
.reconnectServers(userId) void getOAuthReconnectionManager()
.catch((err) => { .reconnectServers(userId)
logger.error('Error reconnecting OAuth MCP servers:', err); .catch((err) => {
}); logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err);
});
} catch (err) {
logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err);
}
res.status(200).send({ token, user }); res.status(200).send({ token, user });
} else if (req?.query?.retry) { } else if (req?.query?.retry) {

View file

@ -8,6 +8,7 @@ const {
Tokenizer, Tokenizer,
checkAccess, checkAccess,
logAxiosError, logAxiosError,
sanitizeTitle,
resolveHeaders, resolveHeaders,
getBalanceConfig, getBalanceConfig,
memoryInstructions, memoryInstructions,
@ -775,6 +776,7 @@ class AgentClient extends BaseClient {
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents]; const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];
config = { config = {
runName: 'AgentRun',
configurable: { configurable: {
thread_id: this.conversationId, thread_id: this.conversationId,
last_agent_index: this.agentConfigs?.size ?? 0, last_agent_index: this.agentConfigs?.size ?? 0,
@ -1233,6 +1235,10 @@ class AgentClient extends BaseClient {
handleLLMEnd, handleLLMEnd,
}, },
], ],
configurable: {
thread_id: this.conversationId,
user_id: this.user ?? this.options.req.user?.id,
},
}, },
}); });
@ -1270,7 +1276,7 @@ class AgentClient extends BaseClient {
); );
}); });
return titleResult.title; return sanitizeTitle(titleResult.title);
} catch (err) { } catch (err) {
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err); logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
return; return;

View file

@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({
}), }),
})); }));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
}));
describe('AgentClient - titleConvo', () => { describe('AgentClient - titleConvo', () => {
let client; let client;
let mockRun; let mockRun;
@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => {
expect(result).toBe('Generated Title'); expect(result).toBe('Generated Title');
}); });
it('should sanitize the generated title by removing think blocks', async () => {
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
mockRun.generateTitle.mockResolvedValue({
title: titleWithThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should remove the <think> block and return only the clean title
expect(result).toBe('User Hi Greeting');
expect(result).not.toContain('<think>');
expect(result).not.toContain('</think>');
});
it('should return fallback title when sanitization results in empty string', async () => {
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
mockRun.generateTitle.mockResolvedValue({
title: titleOnlyThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return the fallback title since sanitization would result in empty string
expect(result).toBe('Untitled Conversation');
});
it('should handle errors gracefully and return undefined', async () => { it('should handle errors gracefully and return undefined', async () => {
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed')); mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));

View file

@ -57,7 +57,7 @@ async function loadConfigModels(req) {
for (let i = 0; i < customEndpoints.length; i++) { for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i]; const endpoint = customEndpoints[i];
const { models, name: configName, baseURL, apiKey } = endpoint; const { models, name: configName, baseURL, apiKey, headers: endpointHeaders } = endpoint;
const name = normalizeEndpointName(configName); const name = normalizeEndpointName(configName);
endpointsMap[name] = endpoint; endpointsMap[name] = endpoint;
@ -76,6 +76,8 @@ async function loadConfigModels(req) {
apiKey: API_KEY, apiKey: API_KEY,
baseURL: BASE_URL, baseURL: BASE_URL,
user: req.user.id, user: req.user.id,
userObject: req.user,
headers: endpointHeaders,
direct: endpoint.directEndpoint, direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery, userIdQuery: models.userIdQuery,
}); });

View file

@ -134,16 +134,16 @@ const initializeAgent = async ({
}); });
const tokensModel = const tokensModel =
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model; agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model;
const maxTokens = optionalChainWithEmptyCheck( const maxOutputTokens = optionalChainWithEmptyCheck(
modelOptions.maxOutputTokens, options.llmConfig?.maxOutputTokens,
modelOptions.maxTokens, options.llmConfig?.maxTokens,
0, 0,
); );
const agentMaxContextTokens = optionalChainWithEmptyCheck( const agentMaxContextTokens = optionalChainWithEmptyCheck(
maxContextTokens, maxContextTokens,
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig), getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
4096, 18000,
); );
if ( if (
@ -203,7 +203,7 @@ const initializeAgent = async ({
userMCPAuthMap, userMCPAuthMap,
toolContextMap, toolContextMap,
useLegacyContent: !!options.useLegacyContent, useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9), maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9),
}; };
}; };

View file

@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-
const { loadAgent } = require('~/models/Agent'); const { loadAgent } = require('~/models/Agent');
const buildOptions = (req, endpoint, parsedBody, endpointType) => { const buildOptions = (req, endpoint, parsedBody, endpointType) => {
const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody; const { spec, iconURL, agent_id, ...model_parameters } = parsedBody;
const agentPromise = loadAgent({ const agentPromise = loadAgent({
req, req,
spec,
agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID,
endpoint, endpoint,
model_parameters, model_parameters,
@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => {
endpoint, endpoint,
agent_id, agent_id,
endpointType, endpointType,
instructions,
model_parameters, model_parameters,
agent: agentPromise, agent: agentPromise,
}); });

View file

@ -1,4 +1,3 @@
const { Providers } = require('@librechat/agents');
const { const {
resolveHeaders, resolveHeaders,
isUserProvided, isUserProvided,
@ -143,39 +142,27 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
if (optionsOnly) { if (optionsOnly) {
const modelOptions = endpointOption?.model_parameters ?? {}; const modelOptions = endpointOption?.model_parameters ?? {};
if (endpoint !== Providers.OLLAMA) { clientOptions = Object.assign(
clientOptions = Object.assign( {
{ modelOptions,
modelOptions, },
}, clientOptions,
clientOptions, );
); clientOptions.modelOptions.user = req.user.id;
clientOptions.modelOptions.user = req.user.id; const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
const options = getOpenAIConfig(apiKey, clientOptions, endpoint); if (options != null) {
if (options != null) { options.useLegacyContent = true;
options.useLegacyContent = true; options.endpointTokenConfig = endpointTokenConfig;
options.endpointTokenConfig = endpointTokenConfig; }
} if (!clientOptions.streamRate) {
if (!clientOptions.streamRate) {
return options;
}
options.llmConfig.callbacks = [
{
handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
},
];
return options; return options;
} }
options.llmConfig.callbacks = [
if (clientOptions.reverseProxyUrl) { {
modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0]; handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate),
delete clientOptions.reverseProxyUrl; },
} ];
return options;
return {
useLegacyContent: true,
llmConfig: modelOptions,
};
} }
const client = new OpenAIClient(apiKey, clientOptions); const client = new OpenAIClient(apiKey, clientOptions);

View file

@ -159,7 +159,7 @@ const initializeClient = async ({
modelOptions.model = modelName; modelOptions.model = modelName;
clientOptions = Object.assign({ modelOptions }, clientOptions); clientOptions = Object.assign({ modelOptions }, clientOptions);
clientOptions.modelOptions.user = req.user.id; clientOptions.modelOptions.user = req.user.id;
const options = getOpenAIConfig(apiKey, clientOptions); const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
if (options != null && serverless === true) { if (options != null && serverless === true) {
options.useLegacyContent = true; options.useLegacyContent = true;
} }

View file

@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) {
settings.advancedMode = speechTab.advancedMode; settings.advancedMode = speechTab.advancedMode;
} }
if (speechTab.speechToText) { if (speechTab.speechToText !== undefined) {
for (const key in speechTab.speechToText) { if (typeof speechTab.speechToText === 'boolean') {
if (speechTab.speechToText[key] !== undefined) { settings.speechToText = speechTab.speechToText;
settings[key] = speechTab.speechToText[key]; } else {
for (const key in speechTab.speechToText) {
if (speechTab.speechToText[key] !== undefined) {
settings[key] = speechTab.speechToText[key];
}
} }
} }
} }
if (speechTab.textToSpeech) { if (speechTab.textToSpeech !== undefined) {
for (const key in speechTab.textToSpeech) { if (typeof speechTab.textToSpeech === 'boolean') {
if (speechTab.textToSpeech[key] !== undefined) { settings.textToSpeech = speechTab.textToSpeech;
settings[key] = speechTab.textToSpeech[key]; } else {
for (const key in speechTab.textToSpeech) {
if (speechTab.textToSpeech[key] !== undefined) {
settings[key] = speechTab.textToSpeech[key];
}
} }
} }
} }

View file

@ -598,11 +598,22 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) {
throw new Error('OCR capability is not enabled for Agents'); throw new Error('OCR capability is not enabled for Agents');
} else if (shouldUseOCR) { } else if (shouldUseOCR) {
const { handleFileUpload: uploadOCR } = getStrategyFunctions( try {
appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, const { handleFileUpload: uploadOCR } = getStrategyFunctions(
); appConfig?.ocr?.strategy ?? FileSources.mistral_ocr,
const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues }); );
return await createTextFile({ text, bytes, filepath: ocrFileURL }); const {
text,
bytes,
filepath: ocrFileURL,
} = await uploadOCR({ req, file, loadAuthValues });
return await createTextFile({ text, bytes, filepath: ocrFileURL });
} catch (ocrError) {
logger.error(
`[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`,
ocrError,
);
}
} }
const shouldUseSTT = fileConfig.checkType( const shouldUseSTT = fileConfig.checkType(

View file

@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
/** /**
* Get current user's Entra ID group memberships from Microsoft Graph * Get current user's Entra ID group memberships from Microsoft Graph
* Uses /me/memberOf endpoint to get groups the user is a member of * Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of
* @param {string} accessToken - OpenID Connect access token * @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier * @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs) * @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li
const getUserEntraGroups = async (accessToken, sub) => { const getUserEntraGroups = async (accessToken, sub) => {
try { try {
const graphClient = await createGraphClient(accessToken, sub); const graphClient = await createGraphClient(accessToken, sub);
const response = await graphClient
.api('/me/getMemberGroups')
.post({ securityEnabledOnly: false });
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get(); const groupIds = Array.isArray(response?.value) ? response.value : [];
return [...new Set(groupIds.map((groupId) => String(groupId)))];
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) { } catch (error) {
logger.error('[getUserEntraGroups] Error fetching user groups:', error); logger.error('[getUserEntraGroups] Error fetching user groups:', error);
return []; return [];
@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => {
const getUserOwnedEntraGroups = async (accessToken, sub) => { const getUserOwnedEntraGroups = async (accessToken, sub) => {
try { try {
const graphClient = await createGraphClient(accessToken, sub); const graphClient = await createGraphClient(accessToken, sub);
const allGroupIds = [];
let nextLink = '/me/ownedObjects/microsoft.graph.group';
const groupsResponse = await graphClient while (nextLink) {
.api('/me/ownedObjects/microsoft.graph.group') const response = await graphClient.api(nextLink).select('id').top(999).get();
.select('id') const groups = response?.value || [];
.get(); allGroupIds.push(...groups.map((group) => group.id));
return (groupsResponse.value || []).map((group) => group.id); nextLink = response['@odata.nextLink']
? response['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null;
}
return allGroupIds;
} catch (error) { } catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return []; return [];
@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => {
const getGroupMembers = async (accessToken, sub, groupId) => { const getGroupMembers = async (accessToken, sub, groupId) => {
try { try {
const graphClient = await createGraphClient(accessToken, sub); const graphClient = await createGraphClient(accessToken, sub);
const allMembers = []; const allMembers = new Set();
let nextLink = `/groups/${groupId}/members`; let nextLink = `/groups/${groupId}/transitiveMembers`;
while (nextLink) { while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || []; const members = membersResponse?.value || [];
allMembers.push(...members.map((member) => member.id)); members.forEach((member) => {
if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') {
allMembers.add(member.id);
}
});
nextLink = membersResponse['@odata.nextLink'] nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1] ? membersResponse['@odata.nextLink']
.replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '')
.trim() || null
: null; : null;
} }
return allMembers; return Array.from(allMembers);
} catch (error) { } catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error); logger.error('[getGroupMembers] Error fetching group members:', error);
return []; return [];

View file

@ -73,6 +73,7 @@ describe('GraphApiService', () => {
header: jest.fn().mockReturnThis(), header: jest.fn().mockReturnThis(),
top: jest.fn().mockReturnThis(), top: jest.fn().mockReturnThis(),
get: jest.fn(), get: jest.fn(),
post: jest.fn(),
}; };
Client.init.mockReturnValue(mockGraphClient); Client.init.mockReturnValue(mockGraphClient);
@ -514,31 +515,33 @@ describe('GraphApiService', () => {
}); });
describe('getUserEntraGroups', () => { describe('getUserEntraGroups', () => {
it('should fetch user groups from memberOf endpoint', async () => { it('should fetch user groups using getMemberGroups endpoint', async () => {
const mockGroupsResponse = { const mockGroupsResponse = {
value: [ value: ['group-1', 'group-2'],
{
id: 'group-1',
},
{
id: 'group-2',
},
],
}; };
mockGraphClient.get.mockResolvedValue(mockGroupsResponse); mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user'); const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf'); expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups');
expect(mockGraphClient.select).toHaveBeenCalledWith('id'); expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false });
expect(result).toEqual(['group-1', 'group-2']);
});
it('should deduplicate returned group ids', async () => {
mockGraphClient.post.mockResolvedValue({
value: ['group-1', 'group-2', 'group-1'],
});
const result = await GraphApiService.getUserEntraGroups('token', 'user');
expect(result).toHaveLength(2);
expect(result).toEqual(['group-1', 'group-2']); expect(result).toEqual(['group-1', 'group-2']);
}); });
it('should return empty array on error', async () => { it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error')); mockGraphClient.post.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserEntraGroups('token', 'user'); const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -550,7 +553,7 @@ describe('GraphApiService', () => {
value: [], value: [],
}; };
mockGraphClient.get.mockResolvedValue(mockGroupsResponse); mockGraphClient.post.mockResolvedValue(mockGroupsResponse);
const result = await GraphApiService.getUserEntraGroups('token', 'user'); const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -558,7 +561,7 @@ describe('GraphApiService', () => {
}); });
it('should handle missing value property', async () => { it('should handle missing value property', async () => {
mockGraphClient.get.mockResolvedValue({}); mockGraphClient.post.mockResolvedValue({});
const result = await GraphApiService.getUserEntraGroups('token', 'user'); const result = await GraphApiService.getUserEntraGroups('token', 'user');
@ -566,6 +569,89 @@ describe('GraphApiService', () => {
}); });
}); });
describe('getUserOwnedEntraGroups', () => {
it('should fetch owned groups with pagination support', async () => {
const firstPage = {
value: [
{
id: 'owned-group-1',
},
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
};
const secondPage = {
value: [
{
id: 'owned-group-2',
},
],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
1,
'/me/ownedObjects/microsoft.graph.group',
);
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(mockGraphClient.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(['owned-group-1', 'owned-group-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user');
expect(result).toEqual([]);
});
});
describe('getGroupMembers', () => {
it('should fetch transitive members and include only users', async () => {
const firstPage = {
value: [
{ id: 'user-1', '@odata.type': '#microsoft.graph.user' },
{ id: 'child-group', '@odata.type': '#microsoft.graph.group' },
],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc',
};
const secondPage = {
value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }],
};
mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage);
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers');
expect(mockGraphClient.api).toHaveBeenNthCalledWith(
2,
'/groups/group-id/transitiveMembers?$skiptoken=abc',
);
expect(mockGraphClient.top).toHaveBeenCalledWith(999);
expect(result).toEqual(['user-1', 'user-2']);
});
it('should return empty array on error', async () => {
mockGraphClient.get.mockRejectedValue(new Error('API error'));
const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id');
expect(result).toEqual([]);
});
});
describe('testGraphApiAccess', () => { describe('testGraphApiAccess', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();

View file

@ -39,6 +39,8 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter. * @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response. * @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
* @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted. * @param {string} [params.tokenKey] - The cache key to save the token configuration. Uses `name` if omitted.
* @param {Record<string, string>} [params.headers] - Optional headers for the request.
* @param {Partial<IUser>} [params.userObject] - Optional user object for header resolution.
* @returns {Promise<string[]>} A promise that resolves to an array of model identifiers. * @returns {Promise<string[]>} A promise that resolves to an array of model identifiers.
* @async * @async
*/ */
@ -52,6 +54,8 @@ const fetchModels = async ({
userIdQuery = false, userIdQuery = false,
createTokenConfig = true, createTokenConfig = true,
tokenKey, tokenKey,
headers,
userObject,
}) => { }) => {
let models = []; let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL; const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
@ -65,7 +69,13 @@ const fetchModels = async ({
} }
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) { if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
return await OllamaClient.fetchModels(baseURL); try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) {
const logMessage =
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
logAxiosError({ message: logMessage, error: ollamaError });
}
} }
try { try {

View file

@ -1,5 +1,5 @@
const axios = require('axios'); const axios = require('axios');
const { logger } = require('@librechat/data-schemas'); const { logAxiosError, resolveHeaders } = require('@librechat/api');
const { EModelEndpoint, defaultModels } = require('librechat-data-provider'); const { EModelEndpoint, defaultModels } = require('librechat-data-provider');
const { const {
@ -18,6 +18,8 @@ jest.mock('@librechat/api', () => {
processModelData: jest.fn((...args) => { processModelData: jest.fn((...args) => {
return originalUtils.processModelData(...args); return originalUtils.processModelData(...args);
}), }),
logAxiosError: jest.fn(),
resolveHeaders: jest.fn((options) => options?.headers || {}),
}; };
}); });
@ -277,12 +279,51 @@ describe('fetchModels with Ollama specific logic', () => {
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']); expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', { expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: {},
timeout: 5000, timeout: 5000,
}); });
}); });
it('should handle errors gracefully when fetching Ollama models fails', async () => { it('should pass headers and user object to Ollama fetchModels', async () => {
axios.get.mockRejectedValue(new Error('Network error')); const customHeaders = {
'Content-Type': 'application/json',
Authorization: 'Bearer custom-token',
};
const userObject = {
id: 'user789',
email: 'test@example.com',
};
resolveHeaders.mockReturnValueOnce(customHeaders);
const models = await fetchModels({
user: 'user789',
apiKey: 'testApiKey',
baseURL: 'https://api.ollama.test.com',
name: 'ollama',
headers: customHeaders,
userObject,
});
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
expect(resolveHeaders).toHaveBeenCalledWith({
headers: customHeaders,
user: userObject,
});
expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
headers: customHeaders,
timeout: 5000,
});
});
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
axios.get.mockRejectedValueOnce(new Error('Ollama API error'));
axios.get.mockResolvedValueOnce({
data: {
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
},
});
const models = await fetchModels({ const models = await fetchModels({
user: 'user789', user: 'user789',
apiKey: 'testApiKey', apiKey: 'testApiKey',
@ -290,8 +331,13 @@ describe('fetchModels with Ollama specific logic', () => {
name: 'OllamaAPI', name: 'OllamaAPI',
}); });
expect(models).toEqual([]); expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
expect(logger.error).toHaveBeenCalled(); expect(logAxiosError).toHaveBeenCalledWith({
message:
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
error: expect.any(Error),
});
expect(axios.get).toHaveBeenCalledTimes(2);
}); });
it('should return an empty array if no baseURL is provided', async () => { it('should return an empty array if no baseURL is provided', async () => {

View file

@ -357,16 +357,18 @@ async function setupOpenId() {
}; };
const appConfig = await getAppConfig(); const appConfig = await getAppConfig();
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) { /** Azure AD sometimes doesn't return email, use preferred_username as fallback */
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error( logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
); );
return done(null, false, { message: 'Email domain not allowed' }); return done(null, false, { message: 'Email domain not allowed' });
} }
const result = await findOpenIDUser({ const result = await findOpenIDUser({
findUser, findUser,
email: claims.email, email: email,
openidId: claims.sub, openidId: claims.sub,
idOnTheSource: claims.oid, idOnTheSource: claims.oid,
strategyName: 'openidStrategy', strategyName: 'openidStrategy',
@ -433,7 +435,7 @@ async function setupOpenId() {
provider: 'openid', provider: 'openid',
openidId: userinfo.sub, openidId: userinfo.sub,
username, username,
email: userinfo.email || '', email: email || '',
emailVerified: userinfo.email_verified || false, emailVerified: userinfo.email_verified || false,
name: fullName, name: fullName,
idOnTheSource: userinfo.oid, idOnTheSource: userinfo.oid,
@ -447,8 +449,8 @@ async function setupOpenId() {
user.username = username; user.username = username;
user.name = fullName; user.name = fullName;
user.idOnTheSource = userinfo.oid; user.idOnTheSource = userinfo.oid;
if (userinfo.email && userinfo.email !== user.email) { if (email && email !== user.email) {
user.email = userinfo.email; user.email = email;
user.emailVerified = userinfo.email_verified || false; user.emailVerified = userinfo.email_verified || false;
} }
} }

View file

@ -186,6 +186,19 @@ describe('getModelMaxTokens', () => {
); );
}); });
test('should return correct tokens for gpt-5-pro matches', () => {
expect(getModelMaxTokens('gpt-5-pro')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro']);
expect(getModelMaxTokens('gpt-5-pro-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
expect(getModelMaxTokens('openai/gpt-5-pro')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
expect(getModelMaxTokens('gpt-5-pro-2025-01-30')).toBe(
maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'],
);
});
test('should return correct tokens for Anthropic models', () => { test('should return correct tokens for Anthropic models', () => {
const models = [ const models = [
'claude-2.1', 'claude-2.1',
@ -469,7 +482,7 @@ describe('getModelMaxTokens', () => {
test('should return correct max output tokens for GPT-5 models', () => { test('should return correct max output tokens for GPT-5 models', () => {
const { getModelMaxOutputTokens } = require('@librechat/api'); const { getModelMaxOutputTokens } = require('@librechat/api');
['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => { ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => {
expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]); expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]);
expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe( expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe(
maxOutputTokensMap[EModelEndpoint.openAI][model], maxOutputTokensMap[EModelEndpoint.openAI][model],
@ -582,6 +595,13 @@ describe('matchModelName', () => {
expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano'); expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano');
}); });
it('should return the closest matching key for gpt-5-pro matches', () => {
expect(matchModelName('openai/gpt-5-pro')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-preview')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro');
expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro');
});
// Tests for Google models // Tests for Google models
it('should return the exact model name if it exists in maxTokensMap - Google models', () => { it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k'); expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
@ -832,6 +852,49 @@ describe('Claude Model Tests', () => {
); );
}); });
it('should return correct context length for Claude Haiku 4.5', () => {
expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
expect(getModelMaxTokens('claude-haiku-4-5')).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
});
it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
expect(modelKey).toBe('claude-haiku-4-5');
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'],
);
});
});
it('should match model names correctly for Claude Haiku 4.5', () => {
const modelVariations = [
'claude-haiku-4-5',
'claude-haiku-4-5-20250420',
'claude-haiku-4-5-latest',
'anthropic/claude-haiku-4-5',
'claude-haiku-4-5/anthropic',
'claude-haiku-4-5-preview',
];
modelVariations.forEach((model) => {
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-haiku-4-5');
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [ const modelVariations = [
'claude-sonnet-4', 'claude-sonnet-4',
@ -924,6 +987,121 @@ describe('Kimi Model Tests', () => {
}); });
}); });
describe('Qwen3 Model Tests', () => {
describe('getModelMaxTokens', () => {
test('should return correct tokens for Qwen3 base pattern', () => {
expect(getModelMaxTokens('qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
});
test('should return correct tokens for qwen3-4b (falls back to qwen3)', () => {
expect(getModelMaxTokens('qwen3-4b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
});
test('should return correct tokens for Qwen3 base models', () => {
expect(getModelMaxTokens('qwen3-8b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-8b']);
expect(getModelMaxTokens('qwen3-14b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-14b']);
expect(getModelMaxTokens('qwen3-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-32b']);
expect(getModelMaxTokens('qwen3-235b-a22b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-235b-a22b'],
);
});
test('should return correct tokens for Qwen3 VL (Vision-Language) models', () => {
expect(getModelMaxTokens('qwen3-vl-8b-thinking')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-thinking'],
);
expect(getModelMaxTokens('qwen3-vl-8b-instruct')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
);
expect(getModelMaxTokens('qwen3-vl-30b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-30b-a3b'],
);
expect(getModelMaxTokens('qwen3-vl-235b-a22b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-235b-a22b'],
);
});
test('should return correct tokens for Qwen3 specialized models', () => {
expect(getModelMaxTokens('qwen3-max')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-max']);
expect(getModelMaxTokens('qwen3-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
);
expect(getModelMaxTokens('qwen3-coder-30b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-30b-a3b'],
);
expect(getModelMaxTokens('qwen3-coder-plus')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-plus'],
);
expect(getModelMaxTokens('qwen3-coder-flash')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-flash'],
);
expect(getModelMaxTokens('qwen3-next-80b-a3b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-next-80b-a3b'],
);
});
test('should handle Qwen3 models with provider prefixes', () => {
expect(getModelMaxTokens('alibaba/qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
expect(getModelMaxTokens('alibaba/qwen3-4b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
);
expect(getModelMaxTokens('qwen/qwen3-8b')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
);
expect(getModelMaxTokens('openrouter/qwen3-max')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
);
expect(getModelMaxTokens('alibaba/qwen3-vl-8b-instruct')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'],
);
expect(getModelMaxTokens('qwen/qwen3-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'],
);
});
test('should handle Qwen3 models with suffixes', () => {
expect(getModelMaxTokens('qwen3-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']);
expect(getModelMaxTokens('qwen3-4b-preview')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3'],
);
expect(getModelMaxTokens('qwen3-8b-latest')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'],
);
expect(getModelMaxTokens('qwen3-max-2024')).toBe(
maxTokensMap[EModelEndpoint.openAI]['qwen3-max'],
);
});
});
describe('matchModelName', () => {
test('should match exact Qwen3 model names', () => {
expect(matchModelName('qwen3')).toBe('qwen3');
expect(matchModelName('qwen3-4b')).toBe('qwen3');
expect(matchModelName('qwen3-8b')).toBe('qwen3-8b');
expect(matchModelName('qwen3-vl-8b-thinking')).toBe('qwen3-vl-8b-thinking');
expect(matchModelName('qwen3-max')).toBe('qwen3-max');
expect(matchModelName('qwen3-coder')).toBe('qwen3-coder');
});
test('should match Qwen3 model variations with provider prefixes', () => {
expect(matchModelName('alibaba/qwen3')).toBe('qwen3');
expect(matchModelName('alibaba/qwen3-4b')).toBe('qwen3');
expect(matchModelName('qwen/qwen3-8b')).toBe('qwen3-8b');
expect(matchModelName('openrouter/qwen3-max')).toBe('qwen3-max');
expect(matchModelName('alibaba/qwen3-vl-8b-instruct')).toBe('qwen3-vl-8b-instruct');
expect(matchModelName('qwen/qwen3-coder')).toBe('qwen3-coder');
});
test('should match Qwen3 model variations with suffixes', () => {
expect(matchModelName('qwen3-preview')).toBe('qwen3');
expect(matchModelName('qwen3-4b-preview')).toBe('qwen3');
expect(matchModelName('qwen3-8b-latest')).toBe('qwen3-8b');
expect(matchModelName('qwen3-max-2024')).toBe('qwen3-max');
expect(matchModelName('qwen3-coder-v1')).toBe('qwen3-coder');
});
});
});
describe('GLM Model Tests (Zhipu AI)', () => { describe('GLM Model Tests (Zhipu AI)', () => {
describe('getModelMaxTokens', () => { describe('getModelMaxTokens', () => {
test('should return correct tokens for GLM models', () => { test('should return correct tokens for GLM models', () => {

View file

@ -1,4 +1,4 @@
/** v0.8.0 */ /** v0.8.1-rc1 */
module.exports = { module.exports = {
roots: ['<rootDir>/src'], roots: ['<rootDir>/src'],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/frontend", "name": "@librechat/frontend",
"version": "v0.8.0", "version": "v0.8.1-rc1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -149,7 +149,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^6.3.6", "vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1", "vite-plugin-compression2": "^2.2.1",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2" "vite-plugin-pwa": "^0.21.2"

View file

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from 'react-router-dom';
@ -8,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y'; import { LiveAnnouncer } from '~/a11y';
import { router } from './routes'; import { router } from './routes';
@ -24,6 +26,10 @@ const App = () => {
}), }),
}); });
useEffect(() => {
initializeFontSize();
}, []);
// Load theme from environment variables if available // Load theme from environment variables if available
const envTheme = getThemeFromEnv(); const envTheme = getThemeFromEnv();

View file

@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !isEphemeralAgent(agent_id), enabled: !isEphemeralAgent(agent_id),
}); });
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, { const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
enabled: !isEphemeralAgent(agent_id),
});
const { data: mcpData } = useMCPToolsQuery({ const { data: mcpData } = useMCPToolsQuery({
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null, enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,

View file

@ -11,9 +11,9 @@ import {
AgentListResponse, AgentListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useLocalize, useDefaultConvo } from '~/hooks'; import { useLocalize, useDefaultConvo } from '~/hooks';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import { renderAgentAvatar } from '~/utils';
interface SupportContact { interface SupportContact {
name?: string; name?: string;
@ -56,10 +56,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id); localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
queryClient.setQueryData<t.TMessage[]>( clearMessagesCache(queryClient, conversation?.conversationId);
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]); queryClient.invalidateQueries([QueryKeys.messages]);
/** Template with agent configuration */ /** Template with agent configuration */

View file

@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client'; import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider'; import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { ContextType } from '~/common'; import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks'; import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelProvider, useChatContext } from '~/Providers';
import { SidePanelGroup } from '~/components/SidePanel'; import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus'; import { OpenSidebar } from '~/components/Chat/Menus';
import { cn, clearMessagesCache } from '~/utils';
import CategoryTabs from './CategoryTabs'; import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail'; import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid'; import AgentGrid from './AgentGrid';
import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
interface AgentMarketplaceProps { interface AgentMarketplaceProps {
@ -224,10 +224,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
window.open('/c/new', '_blank'); window.open('/c/new', '_blank');
return; return;
} }
queryClient.setQueryData<t.TMessage[]>( clearMessagesCache(queryClient, conversation?.conversationId);
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]); queryClient.invalidateQueries([QueryKeys.messages]);
newConversation(); newConversation();
}; };

View file

@ -58,6 +58,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
value={field.value.toString()} value={field.value.toString()}
aria-label={label}
/> />
)} )}
/> />

View file

@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
// Performance check: rendering should be fast // Performance check: rendering should be fast
const renderTime = endTime - startTime; const renderTime = endTime - startTime;
expect(renderTime).toBeLessThan(720); expect(renderTime).toBeLessThan(740);
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);

View file

@ -129,7 +129,11 @@ const BookmarkForm = ({
</div> </div>
<div className="mt-4 grid w-full items-center gap-2"> <div className="mt-4 grid w-full items-center gap-2">
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium"> <Label
id="bookmark-description-label"
htmlFor="bookmark-description"
className="text-left text-sm font-medium"
>
{localize('com_ui_bookmarks_description')} {localize('com_ui_bookmarks_description')}
</Label> </Label>
<TextareaAutosize <TextareaAutosize
@ -147,6 +151,7 @@ const BookmarkForm = ({
className={cn( className={cn(
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none', 'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
)} )}
aria-labelledby="bookmark-description-label"
/> />
</div> </div>
{conversationId != null && conversationId && ( {conversationId != null && conversationId && (
@ -161,6 +166,7 @@ const BookmarkForm = ({
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value?.toString()} value={field.value?.toString()}
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
/> />
)} )}
/> />

View file

@ -12,6 +12,7 @@ import {
import { import {
useTextarea, useTextarea,
useAutoSave, useAutoSave,
useLocalize,
useRequiresKey, useRequiresKey,
useHandleKeyUp, useHandleKeyUp,
useQueryParams, useQueryParams,
@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null); const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null); const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef); useFocusChatEffect(textAreaRef);
const localize = useLocalize();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [, setIsScrollable] = useState(false); const [, setIsScrollable] = useState(false);
@ -220,6 +222,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}> <div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( {showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention <Mention
conversation={conversation}
setShowMentionPopover={setShowPlusPopover} setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation} newConversation={generateConversation}
textAreaRef={textAreaRef} textAreaRef={textAreaRef}
@ -230,6 +233,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)} )}
{showMentionPopover && ( {showMentionPopover && (
<Mention <Mention
conversation={conversation}
setShowMentionPopover={setShowMentionPopover} setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation} newConversation={newConversation}
textAreaRef={textAreaRef} textAreaRef={textAreaRef}
@ -277,6 +281,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
setIsTextAreaFocused(true); setIsTextAreaFocused(true);
}} }}
onBlur={setIsTextAreaFocused.bind(null, false)} onBlur={setIsTextAreaFocused.bind(null, false)}
aria-label={localize('com_ui_message_input')}
onClick={handleFocusOrClick} onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }} style={{ height: 44, overflowY: 'auto' }}
className={cn( className={cn(

View file

@ -62,17 +62,28 @@ const FileUpload: React.FC<FileUploadProps> = ({
statusText = invalidText ?? localize('com_ui_upload_invalid'); statusText = invalidText ?? localize('com_ui_upload_invalid');
} }
const handleClick = () => {
const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
};
return ( return (
<label <>
htmlFor={`file-upload-${id}`} <button
className={cn( type="button"
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500', onClick={handleClick}
statusColor, className={cn(
containerClassName, 'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
)} statusColor,
> containerClassName,
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" /> )}
<span className="flex text-xs">{statusText}</span> aria-label={statusText}
>
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" aria-hidden="true" />
<span className="flex text-xs">{statusText}</span>
</button>
<input <input
id={`file-upload-${id}`} id={`file-upload-${id}`}
value="" value=""
@ -80,8 +91,9 @@ const FileUpload: React.FC<FileUploadProps> = ({
className={cn('hidden', className)} className={cn('hidden', className)}
accept=".json" accept=".json"
onChange={handleFileChange} onChange={handleFileChange}
tabIndex={-1}
/> />
</label> </>
); );
}; };

View file

@ -122,7 +122,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}> <Button
variant="outline"
aria-label={localize('com_files_filter_by')}
className={cn('min-w-[40px]', isSmallScreen && 'px-2 py-1')}
>
<ListFilter className="size-3.5 sm:size-4" /> <ListFilter className="size-3.5 sm:size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View file

@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { useCombobox } from '@librechat/client'; import { useCombobox } from '@librechat/client';
import { AutoSizer, List } from 'react-virtualized'; import { AutoSizer, List } from 'react-virtualized';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { MentionOption, ConvoGenerator } from '~/common'; import type { MentionOption, ConvoGenerator } from '~/common';
import type { SetterOrUpdater } from 'recoil'; import type { SetterOrUpdater } from 'recoil';
import useSelectMention from '~/hooks/Input/useSelectMention'; import useSelectMention from '~/hooks/Input/useSelectMention';
@ -14,6 +15,7 @@ import MentionItem from './MentionItem';
const ROW_HEIGHT = 40; const ROW_HEIGHT = 40;
export default function Mention({ export default function Mention({
conversation,
setShowMentionPopover, setShowMentionPopover,
newConversation, newConversation,
textAreaRef, textAreaRef,
@ -21,6 +23,7 @@ export default function Mention({
placeholder = 'com_ui_mention', placeholder = 'com_ui_mention',
includeAssistants = true, includeAssistants = true,
}: { }: {
conversation: TConversation | null;
setShowMentionPopover: SetterOrUpdater<boolean>; setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator; newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>; textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
@ -42,6 +45,7 @@ export default function Mention({
const { onSelectMention } = useSelectMention({ const { onSelectMention } = useSelectMention({
presets, presets,
modelSpecs, modelSpecs,
conversation,
assistantsMap, assistantsMap,
endpointsConfig, endpointsConfig,
newConversation, newConversation,

View file

@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import type { EModelEndpoint } from 'librechat-data-provider'; import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
import { useChatContext } from '~/Providers/ChatContext'; import { useChatContext } from '~/Providers/ChatContext';
interface ModelSelectorChatContextValue { interface ModelSelectorChatContextValue {
@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue {
spec?: string | null; spec?: string | null;
agent_id?: string | null; agent_id?: string | null;
assistant_id?: string | null; assistant_id?: string | null;
conversation: TConversation | null;
newConversation: ReturnType<typeof useChatContext>['newConversation']; newConversation: ReturnType<typeof useChatContext>['newConversation'];
} }
@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN
spec: conversation?.spec, spec: conversation?.spec,
agent_id: conversation?.agent_id, agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id, assistant_id: conversation?.assistant_id,
conversation,
newConversation, newConversation,
}), }),
[ [conversation, newConversation],
conversation?.endpoint,
conversation?.model,
conversation?.spec,
conversation?.agent_id,
conversation?.assistant_id,
newConversation,
],
); );
return ( return (

View file

@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const agentsMap = useAgentsMapContext(); const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext(); const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig } = useGetEndpointsQuery();
const { endpoint, model, spec, agent_id, assistant_id, newConversation } = const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
useModelSelectorChatContext(); useModelSelectorChatContext();
const modelSpecs = useMemo(() => { const modelSpecs = useMemo(() => {
const specs = startupConfig?.modelSpecs?.list ?? []; const specs = startupConfig?.modelSpecs?.list ?? [];
@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
const { onSelectEndpoint, onSelectSpec } = useSelectMention({ const { onSelectEndpoint, onSelectSpec } = useSelectMention({
// presets, // presets,
modelSpecs, modelSpecs,
conversation,
assistantsMap, assistantsMap,
endpointsConfig, endpointsConfig,
newConversation, newConversation,

View file

@ -1,8 +1,8 @@
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import { clearMessagesCache } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
export default function HeaderNewChat() { export default function HeaderNewChat() {
@ -15,10 +15,7 @@ export default function HeaderNewChat() {
window.open('/c/new', '_blank'); window.open('/c/new', '_blank');
return; return;
} }
queryClient.setQueryData<TMessage[]>( clearMessagesCache(queryClient, conversation?.conversationId);
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]); queryClient.invalidateQueries([QueryKeys.messages]);
newConversation(); newConversation();
}; };

View file

@ -59,9 +59,10 @@ const PresetItems: FC<{
</label> </label>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<label <button
htmlFor="file-upload" type="button"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700" className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 focus:ring-ring dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
aria-label={localize('com_ui_clear') + ' ' + localize('com_ui_all')}
> >
<svg <svg
width="24" width="24"
@ -70,11 +71,12 @@ const PresetItems: FC<{
fill="currentColor" fill="currentColor"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="mr-1 flex w-[22px] items-center" className="mr-1 flex w-[22px] items-center"
aria-hidden="true"
> >
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path> <path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
</svg> </svg>
{localize('com_ui_clear')} {localize('com_ui_all')} {localize('com_ui_clear')} {localize('com_ui_all')}
</label> </button>
</DialogTrigger> </DialogTrigger>
<DialogTemplate <DialogTemplate
showCloseButton={false} showCloseButton={false}

View file

@ -168,6 +168,7 @@ const EditMessage = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings, removeFocusRings,
)} )}
aria-label={localize('com_ui_message_input')}
dir={isRTL ? 'rtl' : 'ltr'} dir={isRTL ? 'rtl' : 'ltr'}
/> />
</div> </div>

View file

@ -170,6 +170,7 @@ const EditTextPart = ({
'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4',
removeFocusRings, removeFocusRings,
)} )}
aria-label={localize('com_ui_editable_message')}
dir={isRTL ? 'rtl' : 'ltr'} dir={isRTL ? 'rtl' : 'ltr'}
/> />
</div> </div>

View file

@ -1,10 +1,12 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common'; import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks'; import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts'; import ContentParts from './Content/ContentParts';
import { fontSizeAtom } from '~/store/fontSize';
import SiblingSwitch from './SiblingSwitch'; import SiblingSwitch from './SiblingSwitch';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons'; import HoverButtons from './HoverButtons';
@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) {
regenerateMessage, regenerateMessage,
} = useMessageHelpers(props); } = useMessageHelpers(props);
const fontSize = useRecoilValue(store.fontSize); const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const { children, messageId = null, isCreatedByUser } = message ?? {}; const { children, messageId = null, isCreatedByUser } = message ?? {};

View file

@ -1,10 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { MessagesViewProvider } from '~/Providers'; import { MessagesViewProvider } from '~/Providers';
import { fontSizeAtom } from '~/store/fontSize';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -15,7 +17,7 @@ function MessagesViewContent({
messagesTree?: TMessage[] | null; messagesTree?: TMessage[] | null;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const fontSize = useRecoilValue(store.fontSize); const fontSize = useAtomValue(fontSizeAtom);
const { screenshotTargetRef } = useScreenshot(); const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton); const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1); const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);

View file

@ -1,10 +1,12 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useAuthContext, useLocalize } from '~/hooks'; import { useAuthContext, useLocalize } from '~/hooks';
import type { TMessageProps, TMessageIcon } from '~/common'; import type { TMessageProps, TMessageIcon } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon'; import Icon from '~/components/Chat/Messages/MessageIcon';
import SearchContent from './Content/SearchContent'; import SearchContent from './Content/SearchContent';
import { fontSizeAtom } from '~/store/fontSize';
import SearchButtons from './SearchButtons'; import SearchButtons from './SearchButtons';
import SubRow from './SubRow'; import SubRow from './SubRow';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
); );
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) { export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
const fontSize = useAtomValue(fontSizeAtom);
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay); const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const fontSize = useRecoilValue(store.fontSize);
const { user } = useAuthContext(); const { user } = useAuthContext();
const localize = useLocalize(); const localize = useLocalize();

View file

@ -1,4 +1,5 @@
import React, { useCallback, useMemo, memo } from 'react'; import React, { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider'; import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common'; import type { TMessageProps, TMessageIcon } from '~/common';
@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content'; import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow'; import SubRow from '~/components/Chat/Messages/SubRow';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers'; import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks'; import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils'; import { cn, logger } from '~/utils';
@ -58,8 +60,8 @@ const MessageRender = memo(
isMultiMessage, isMultiMessage,
setCurrentEditId, setCurrentEditId,
}); });
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const hasNoChildren = !(msg?.children?.length ?? 0); const hasNoChildren = !(msg?.children?.length ?? 0);

View file

@ -201,7 +201,6 @@ const Conversations: FC<ConversationsProps> = ({
overscanRowCount={10} overscanRowCount={10}
className="outline-none" className="outline-none"
style={{ outline: 'none' }} style={{ outline: 'none' }}
role="list"
aria-label="Conversations" aria-label="Conversations"
onRowsRendered={handleRowsRendered} onRowsRendered={handleRowsRendered}
tabIndex={-1} tabIndex={-1}

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback } from 'react';
import { QueryKeys } from 'librechat-data-provider'; import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@ -82,7 +82,7 @@ export function DeleteConversationDialog({
<OGDialogHeader> <OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle> <OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
</OGDialogHeader> </OGDialogHeader>
<div> <div className="w-full truncate">
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ? {localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
</div> </div>
<div className="flex justify-end gap-4 pt-4"> <div className="flex justify-end gap-4 pt-4">

View file

@ -77,7 +77,13 @@ export default function ShareButton({
<div className="relative items-center rounded-lg p-2"> <div className="relative items-center rounded-lg p-2">
{showQR && ( {showQR && (
<div className="mb-4 flex flex-col items-center"> <div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" /> <QRCodeSVG
value={sharedLink}
size={200}
marginSize={2}
className="rounded-2xl"
title={localize('com_ui_share_qr_code_description')}
/>
</div> </div>
)} )}
@ -87,6 +93,7 @@ export default function ShareButton({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
aria-label={localize('com_ui_copy_link')}
onClick={() => { onClick={() => {
if (isCopying) { if (isCopying) {
return; return;

View file

@ -34,6 +34,8 @@ const RenameForm: React.FC<RenameFormProps> = ({
case 'Enter': case 'Enter':
onSubmit(titleInput); onSubmit(titleInput);
break; break;
case 'Tab':
break;
} }
}; };
@ -50,22 +52,23 @@ const RenameForm: React.FC<RenameFormProps> = ({
value={titleInput} value={titleInput}
onChange={(e) => setTitleInput(e.target.value)} onChange={(e) => setTitleInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => onSubmit(titleInput)}
maxLength={100} maxLength={100}
aria-label={localize('com_ui_new_conversation_title')} aria-label={localize('com_ui_new_conversation_title')}
/> />
<div className="flex gap-1" role="toolbar"> <div className="flex gap-1" role="toolbar">
<button <button
onClick={() => onCancel()} onClick={() => onCancel()}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2" className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_cancel')} aria-label={localize('com_ui_cancel')}
type="button"
> >
<X className="h-4 w-4" aria-hidden="true" /> <X className="h-4 w-4" aria-hidden="true" />
</button> </button>
<button <button
onClick={() => onSubmit(titleInput)} onClick={() => onSubmit(titleInput)}
className="p-1 hover:opacity-70 focus:outline-none focus:ring-2" className="rounded-md p-1 hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_save')} aria-label={localize('com_ui_save')}
type="button"
> >
<Check className="h-4 w-4" aria-hidden="true" /> <Check className="h-4 w-4" aria-hidden="true" />
</button> </button>

View file

@ -151,6 +151,7 @@ export default function Settings({
min={0} min={0}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="temp-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} /> <OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
@ -160,7 +161,9 @@ export default function Settings({
<div className="flex justify-between"> <div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium"> <Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '} {localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })})
</small>
</Label> </Label>
<InputNumber <InputNumber
id="top-p-int" id="top-p-int"
@ -189,6 +192,7 @@ export default function Settings({
min={0} min={0}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="top-p-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} /> <OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
@ -199,7 +203,9 @@ export default function Settings({
<div className="flex justify-between"> <div className="flex justify-between">
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium"> <Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_frequency_penalty')}{' '} {localize('com_endpoint_frequency_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label> </Label>
<InputNumber <InputNumber
id="freq-penalty-int" id="freq-penalty-int"
@ -228,6 +234,7 @@ export default function Settings({
min={-2} min={-2}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} /> <OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
@ -238,7 +245,9 @@ export default function Settings({
<div className="flex justify-between"> <div className="flex justify-between">
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium"> <Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
{localize('com_endpoint_presence_penalty')}{' '} {localize('com_endpoint_presence_penalty')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label> </Label>
<InputNumber <InputNumber
id="pres-penalty-int" id="pres-penalty-int"
@ -267,6 +276,7 @@ export default function Settings({
min={-2} min={-2}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} /> <OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
@ -306,6 +316,7 @@ export default function Settings({
onCheckedChange={(checked: boolean) => setResendFiles(checked)} onCheckedChange={(checked: boolean) => setResendFiles(checked)}
disabled={readonly} disabled={readonly}
className="flex" className="flex"
aria-label={localize('com_endpoint_plug_resend_files')}
/> />
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} /> <OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
</HoverCardTrigger> </HoverCardTrigger>
@ -323,6 +334,7 @@ export default function Settings({
max={2} max={2}
min={0} min={0}
step={1} step={1}
aria-label={localize('com_endpoint_plug_image_detail')}
/> />
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} /> <OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
</HoverCardTrigger> </HoverCardTrigger>

View file

@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="flex justify-between"> <div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium"> <Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '} {localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '0' })})
</small>
</Label> </Label>
<InputNumber <InputNumber
id="temp-int" id="temp-int"
@ -82,6 +84,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={0} min={0}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="temp-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeAgent} onCheckedChange={onCheckedChangeAgent}
disabled={readonly} disabled={readonly}
className="ml-4 mt-2" className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_use_functions')}
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="func" side={ESide.Bottom} />
@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
onCheckedChange={onCheckedChangeSkip} onCheckedChange={onCheckedChangeSkip}
disabled={readonly} disabled={readonly}
className="ml-4 mt-2" className="ml-4 mt-2"
aria-label={localize('com_endpoint_plug_skip_completion')}
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="skip" side={ESide.Bottom} />

View file

@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.temperature.min} min={google.temperature.min}
step={google.temperature.step} step={google.temperature.step}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="temp-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topP.min} min={google.topP.min}
step={google.topP.step} step={google.topP.step}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="top-p-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.topK.min} min={google.topK.min}
step={google.topK.step} step={google.topK.step}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="top-k-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
min={google.maxOutputTokens.min} min={google.maxOutputTokens.min}
step={google.maxOutputTokens.step} step={google.maxOutputTokens.step}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="max-tokens-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover <OptionHover

View file

@ -256,6 +256,7 @@ export default function Settings({
min={0} min={0}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="temp-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
@ -296,6 +297,7 @@ export default function Settings({
min={0} min={0}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="top-p-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
@ -337,6 +339,7 @@ export default function Settings({
min={-2} min={-2}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="freq-penalty-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="freq" side={ESide.Left} />
@ -378,6 +381,7 @@ export default function Settings({
min={-2} min={-2}
step={0.01} step={0.01}
className="flex h-4 w-full" className="flex h-4 w-full"
aria-labelledby="pres-penalty-int"
/> />
</HoverCardTrigger> </HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} /> <OptionHover endpoint={conversation.endpoint ?? ''} type="pres" side={ESide.Left} />

View file

@ -1,5 +1,6 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react'; import { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common'; import type { TMessageProps, TMessageIcon } from '~/common';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAttachments, useMessageActions } from '~/hooks'; import { useAttachments, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow'; import SubRow from '~/components/Chat/Messages/SubRow';
import { fontSizeAtom } from '~/store/fontSize';
import { cn, logger } from '~/utils'; import { cn, logger } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -60,8 +62,8 @@ const ContentRender = memo(
isMultiMessage, isMultiMessage,
setCurrentEditId, setCurrentEditId,
}); });
const fontSize = useAtomValue(fontSizeAtom);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo( const isLast = useMemo(

View file

@ -124,13 +124,15 @@ export default function ExportModal({
disabled={!exportOptionsSupport} disabled={!exportOptionsSupport}
checked={includeOptions} checked={includeOptions}
onCheckedChange={setIncludeOptions} onCheckedChange={setIncludeOptions}
aria-labelledby="includeOptions-label"
/> />
<label <label
id="includeOptions-label"
htmlFor="includeOptions" htmlFor="includeOptions"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
> >
{exportOptionsSupport {exportOptionsSupport
? localize('com_nav_enabled') ? localize('com_nav_export_include_endpoint_options')
: localize('com_nav_not_supported')} : localize('com_nav_not_supported')}
</label> </label>
</div> </div>
@ -146,13 +148,15 @@ export default function ExportModal({
disabled={!exportBranchesSupport} disabled={!exportBranchesSupport}
checked={exportBranches} checked={exportBranches}
onCheckedChange={setExportBranches} onCheckedChange={setExportBranches}
aria-labelledby="exportBranches-label"
/> />
<label <label
id="exportBranches-label"
htmlFor="exportBranches" htmlFor="exportBranches"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
> >
{exportBranchesSupport {exportBranchesSupport
? localize('com_nav_enabled') ? localize('com_nav_export_all_message_branches')
: localize('com_nav_not_supported')} : localize('com_nav_not_supported')}
</label> </label>
</div> </div>
@ -163,8 +167,14 @@ export default function ExportModal({
{localize('com_nav_export_recursive_or_sequential')} {localize('com_nav_export_recursive_or_sequential')}
</Label> </Label>
<div className="flex h-[40px] w-full items-center space-x-3"> <div className="flex h-[40px] w-full items-center space-x-3">
<Checkbox id="recursive" checked={recursive} onCheckedChange={setRecursive} /> <Checkbox
id="recursive"
checked={recursive}
onCheckedChange={setRecursive}
aria-labelledby="recursive-label"
/>
<label <label
id="recursive-label"
htmlFor="recursive" htmlFor="recursive"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
> >

View file

@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { useLocalize, useNewConvo } from '~/hooks'; import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store'; import store from '~/store';
export default function MobileNav({ export default function MobileNav({
@ -57,10 +58,7 @@ export default function MobileNav({
aria-label={localize('com_ui_new_chat')} aria-label={localize('com_ui_new_chat')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover" className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
onClick={() => { onClick={() => {
queryClient.setQueryData<TMessage[]>( clearMessagesCache(queryClient, conversation?.conversationId);
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]); queryClient.invalidateQueries([QueryKeys.messages]);
newConversation(); newConversation();
}} }}

View file

@ -5,6 +5,7 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client'; import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useLocalize, useNewConvo } from '~/hooks'; import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store'; import store from '~/store';
export default function NewChat({ export default function NewChat({
@ -33,10 +34,7 @@ export default function NewChat({
window.open('/c/new', '_blank'); window.open('/c/new', '_blank');
return; return;
} }
queryClient.setQueryData<TMessage[]>( clearMessagesCache(queryClient, conversation?.conversationId);
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]); queryClient.invalidateQueries([QueryKeys.messages]);
newConvo(); newConvo();
navigate('/c/new', { state: { focusChat: true } }); navigate('/c/new', { state: { focusChat: true } });

View file

@ -1,15 +1,14 @@
import { useRecoilState } from 'recoil'; import { useAtom } from 'jotai';
import { Dropdown, applyFontSize } from '@librechat/client'; import { Dropdown } from '@librechat/client';
import { fontSizeAtom } from '~/store/fontSize';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store';
export default function FontSizeSelector() { export default function FontSizeSelector() {
const [fontSize, setFontSize] = useRecoilState(store.fontSize);
const localize = useLocalize(); const localize = useLocalize();
const [fontSize, setFontSize] = useAtom(fontSizeAtom);
const handleChange = (val: string) => { const handleChange = (val: string) => {
setFontSize(val); setFontSize(val);
applyFontSize(val);
}; };
const options = [ const options = [

View file

@ -30,6 +30,7 @@ export default function SaveBadgesState({
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid="saveBadgesState" data-testid="saveBadgesState"
aria-label={localize('com_nav_save_badges_state')}
/> />
</div> </div>
); );

View file

@ -30,6 +30,7 @@ export default function SaveDraft({
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid="showThinking" data-testid="showThinking"
aria-label={localize('com_nav_show_thinking')}
/> />
</div> </div>
); );

View file

@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils'; import { cn, logger } from '~/utils';
function ImportConversations() { function ImportConversations() {
const queryClient = useQueryClient();
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const localize = useLocalize(); const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const handleSuccess = useCallback(() => { const handleSuccess = useCallback(() => {
@ -53,7 +51,8 @@ function ImportConversations() {
const handleFileUpload = useCallback( const handleFileUpload = useCallback(
async (file: File) => { async (file: File) => {
try { try {
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const maxFileSize = startupConfig?.conversationImportMaxFileSize;
if (maxFileSize && file.size > maxFileSize) { if (maxFileSize && file.size > maxFileSize) {
const size = (maxFileSize / (1024 * 1024)).toFixed(2); const size = (maxFileSize / (1024 * 1024)).toFixed(2);
showToast({ showToast({
@ -76,7 +75,7 @@ function ImportConversations() {
}); });
} }
}, },
[uploadFile, showToast, localize, startupConfig], [uploadFile, showToast, localize, queryClient],
); );
const handleFileChange = useCallback( const handleFileChange = useCallback(

View file

@ -13,7 +13,6 @@ import {
useMediaQuery, useMediaQuery,
OGDialogHeader, OGDialogHeader,
OGDialogTitle, OGDialogTitle,
TooltipAnchor,
DataTable, DataTable,
Spinner, Spinner,
Button, Button,
@ -246,37 +245,27 @@ export default function SharedLinks() {
}, },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TooltipAnchor <Button
description={localize('com_ui_view_source')} variant="ghost"
render={ className="h-8 w-8 p-0 hover:bg-surface-hover"
<Button onClick={() => {
variant="ghost" window.open(`/c/${row.original.conversationId}`, '_blank');
className="h-8 w-8 p-0 hover:bg-surface-hover" }}
onClick={() => { aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
window.open(`/c/${row.original.conversationId}`, '_blank'); >
}} <MessageSquare className="size-4" aria-hidden="true" />
title={localize('com_ui_view_source')} </Button>
> <Button
<MessageSquare className="size-4" /> variant="ghost"
</Button> className="h-8 w-8 p-0 hover:bg-surface-hover"
} onClick={() => {
/> setDeleteRow(row.original);
<TooltipAnchor setIsDeleteOpen(true);
description={localize('com_ui_delete')} }}
render={ aria-label={`${localize('com_ui_delete')} - ${row.original.title || localize('com_ui_untitled')}`}
<Button >
variant="ghost" <TrashIcon className="size-4" aria-hidden="true" />
className="h-8 w-8 p-0 hover:bg-surface-hover" </Button>
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
title={localize('com_ui_delete')}
>
<TrashIcon className="size-4" />
</Button>
}
/>
</div> </div>
), ),
}, },

View file

@ -53,6 +53,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
} }
}} }}
value={field.value.toString()} value={field.value.toString()}
aria-label={label}
/> />
)} )}
/> />
@ -216,7 +217,12 @@ const AdminSettings = () => {
))} ))}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isSubmitting || isLoading} variant="submit"> <Button
type="submit"
disabled={isSubmitting || isLoading}
variant="submit"
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')} {localize('com_ui_save')}
</Button> </Button>
</div> </div>

View file

@ -28,7 +28,7 @@ export default function AlwaysMakeProd({
checked={alwaysMakeProd} checked={alwaysMakeProd}
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
data-testid="alwaysMakeProd" data-testid="alwaysMakeProd"
aria-label="Always make prompt production" aria-label={localize('com_nav_always_make_prod')}
/> />
<div>{localize('com_nav_always_make_prod')} </div> <div>{localize('com_nav_always_make_prod')} </div>
</div> </div>

View file

@ -30,7 +30,7 @@ export default function AutoSendPrompt({
> >
<div> {localize('com_nav_auto_send_prompts')} </div> <div> {localize('com_nav_auto_send_prompts')} </div>
<Switch <Switch
aria-label="toggle-auto-send-prompts" aria-label={localize('com_nav_auto_send_prompts')}
id="autoSendPrompts" id="autoSendPrompts"
checked={autoSendPrompts} checked={autoSendPrompts}
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}

View file

@ -102,6 +102,9 @@ function ChatGroupItem({
e.stopPropagation(); e.stopPropagation();
setPreviewDialogOpen(true); setPreviewDialogOpen(true);
}} }}
onKeyDown={(e) => {
e.stopPropagation();
}}
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
> >
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" /> <TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
@ -116,6 +119,9 @@ function ChatGroupItem({
e.stopPropagation(); e.stopPropagation();
onEditClick(e); onEditClick(e);
}} }}
onKeyDown={(e) => {
e.stopPropagation();
}}
> >
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" /> <EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span> <span>{localize('com_ui_edit')}</span>

View file

@ -151,6 +151,7 @@ const CreatePromptForm = ({
className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200" className="w-full rounded border border-border-medium px-2 py-1 focus:outline-none dark:bg-transparent dark:text-gray-200"
minRows={6} minRows={6}
tabIndex={0} tabIndex={0}
aria-label={localize('com_ui_prompt_input_field')}
/> />
<div <div
className={`mt-1 text-sm text-red-500 ${ className={`mt-1 text-sm text-red-500 ${

View file

@ -34,6 +34,7 @@ export default function List({
variant="outline" variant="outline"
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`} className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
onClick={() => navigate('/d/prompts/new')} onClick={() => navigate('/d/prompts/new')}
aria-label={localize('com_ui_create_prompt')}
> >
<Plus className="size-4" aria-hidden /> <Plus className="size-4" aria-hidden />
{localize('com_ui_create_prompt')} {localize('com_ui_create_prompt')}

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Label } from '@librechat/client'; import { Label } from '@librechat/client';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useLocalize } from '~/hooks';
export default function ListCard({ export default function ListCard({
category, category,
@ -15,6 +16,7 @@ export default function ListCard({
onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLDivElement | HTMLButtonElement>;
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
const localize = useLocalize();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement | HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
@ -31,7 +33,7 @@ export default function ListCard({
tabIndex={0} tabIndex={0}
aria-labelledby={`card-title-${name}`} aria-labelledby={`card-title-${name}`}
aria-describedby={`card-snippet-${name}`} aria-describedby={`card-snippet-${name}`}
aria-label={`Card for ${name}`} aria-label={`${name} Prompt, ${category ? `${localize('com_ui_category')}: ${category}` : ''}`}
> >
<div className="flex w-full justify-between gap-2"> <div className="flex w-full justify-between gap-2">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">

View file

@ -17,6 +17,7 @@ export default function NoPromptGroup() {
onClick={() => { onClick={() => {
navigate('/d/prompts'); navigate('/d/prompts');
}} }}
aria-label={localize('com_ui_back_to_prompts')}
> >
{localize('com_ui_back_to_prompts')} {localize('com_ui_back_to_prompts')}
</Button> </Button>

View file

@ -193,6 +193,7 @@ export default function VariableForm({
)} )}
placeholder={field.config.variable} placeholder={field.config.variable}
maxRows={8} maxRows={8}
aria-label={field.config.variable}
/> />
); );
}} }}
@ -201,7 +202,7 @@ export default function VariableForm({
))} ))}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" variant="submit"> <Button type="submit" variant="submit" aria-label={localize('com_ui_submit')}>
{localize('com_ui_submit')} {localize('com_ui_submit')}
</Button> </Button>
</div> </div>

View file

@ -118,6 +118,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
setIsEditing(false); setIsEditing(false);
} }
}} }}
aria-label={localize('com_ui_prompt_input')}
/> />
) : ( ) : (
<div <div

View file

@ -1,4 +1,4 @@
import { useRecoilValue } from 'recoil'; import { useAtomValue } from 'jotai';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
@ -6,16 +6,16 @@ import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import { Plugin } from '~/components/Messages/Content'; import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow'; import SubRow from '~/components/Chat/Messages/SubRow';
import { fontSizeAtom } from '~/store/fontSize';
import { MessageContext } from '~/Providers'; import { MessageContext } from '~/Providers';
import { useAttachments } from '~/hooks'; import { useAttachments } from '~/hooks';
import MultiMessage from './MultiMessage'; import MultiMessage from './MultiMessage';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store';
import Icon from './MessageIcon'; import Icon from './MessageIcon';
export default function Message(props: TMessageProps) { export default function Message(props: TMessageProps) {
const fontSize = useRecoilValue(store.fontSize); const fontSize = useAtomValue(fontSizeAtom);
const { const {
message, message,
siblingIdx, siblingIdx,

View file

@ -370,7 +370,11 @@ export default function GenericGrantAccessDialog({
<div className="flex gap-2"> <div className="flex gap-2">
<PeoplePickerAdminSettings /> <PeoplePickerAdminSettings />
<OGDialogClose asChild> <OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}> <Button
variant="outline"
onClick={handleCancel}
aria-label={localize('com_ui_cancel')}
>
{localize('com_ui_cancel')} {localize('com_ui_cancel')}
</Button> </Button>
</OGDialogClose> </OGDialogClose>
@ -382,6 +386,7 @@ export default function GenericGrantAccessDialog({
(hasChanges && !hasAtLeastOneOwner) (hasChanges && !hasAtLeastOneOwner)
} }
className="min-w-[120px]" className="min-w-[120px]"
aria-label={localize('com_ui_save_changes')}
> >
{updatePermissionsMutation.isLoading ? ( {updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -60,6 +60,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
value={field.value.toString()} value={field.value.toString()}
aria-label={label}
/> />
)} )}
/> />
@ -158,6 +159,7 @@ const PeoplePickerAdminSettings = () => {
<Button <Button
variant={'outline'} variant={'outline'}
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
> >
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" /> <ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')} {localize('com_ui_admin_settings')}

View file

@ -56,6 +56,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
value={field.value.toString()} value={field.value.toString()}
aria-label={label}
/> />
)} )}
/> />
@ -152,6 +153,7 @@ const AdminSettings = () => {
size={'sm'} size={'sm'}
variant={'outline'} variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
aria-label={localize('com_ui_admin_settings')}
> >
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" /> <ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')} {localize('com_ui_admin_settings')}

View file

@ -17,6 +17,7 @@ const AdvancedButton: React.FC<AdvancedButtonProps> = ({ setActivePanel }) => {
variant={'outline'} variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
onClick={() => setActivePanel(Panel.advanced)} onClick={() => setActivePanel(Panel.advanced)}
aria-label={localize('com_ui_advanced')}
> >
<Settings2 className="h-4 w-4 cursor-pointer" aria-hidden="true" /> <Settings2 className="h-4 w-4 cursor-pointer" aria-hidden="true" />
{localize('com_ui_advanced')} {localize('com_ui_advanced')}

View file

@ -31,6 +31,7 @@ export default function AdvancedPanel() {
onClick={() => { onClick={() => {
setActivePanel(Panel.builder); setActivePanel(Panel.builder);
}} }}
aria-label={localize('com_ui_back_to_builder')}
> >
<div className="advanced-panel-content flex w-full items-center justify-center gap-2"> <div className="advanced-panel-content flex w-full items-center justify-center gap-2">
<ChevronLeft /> <ChevronLeft />

View file

@ -146,6 +146,9 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
<button <button
className="rounded-xl p-1 transition hover:bg-surface-hover" className="rounded-xl p-1 transition hover:bg-surface-hover"
onClick={() => removeAgentAt(idx)} onClick={() => removeAgentAt(idx)}
aria-label={localize('com_ui_remove_agent_from_chain', {
0: getAgentDetails(agentId)?.name || localize('com_ui_agent'),
})}
> >
<X size={18} className="text-text-secondary" /> <X size={18} className="text-text-secondary" />
</button> </button>

View file

@ -186,7 +186,11 @@ function Avatar({
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}> <Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<div className="flex w-full items-center justify-center gap-4"> <div className="flex w-full items-center justify-center gap-4">
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button type="button" className="h-20 w-20"> <button
type="button"
className="f h-20 w-20 focus:rounded-full focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_upload_agent_avatar_label')}
>
{previewUrl ? <AgentAvatarRender url={previewUrl} progress={progress} /> : <NoImage />} {previewUrl ? <AgentAvatarRender url={previewUrl} progress={progress} /> : <NoImage />}
</button> </button>
</Popover.Trigger> </Popover.Trigger>

View file

@ -420,9 +420,16 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
type="text" type="text"
placeholder={localize('com_ui_support_contact_name_placeholder')} placeholder={localize('com_ui_support_contact_name_placeholder')}
aria-label="Support contact name" aria-label="Support contact name"
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'support-contact-name-error' : undefined}
/> />
{error && ( {error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out"> <span
id="support-contact-name-error"
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
aria-live="polite"
>
{error.message} {error.message}
</span> </span>
)} )}
@ -455,9 +462,16 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
type="email" type="email"
placeholder={localize('com_ui_support_contact_email_placeholder')} placeholder={localize('com_ui_support_contact_email_placeholder')}
aria-label="Support contact email" aria-label="Support contact email"
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'support-contact-email-error' : undefined}
/> />
{error && ( {error && (
<span className="text-sm text-red-500 transition duration-300 ease-in-out"> <span
id="support-contact-email-error"
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
aria-live="polite"
>
{error.message} {error.message}
</span> </span>
)} )}

View file

@ -283,6 +283,13 @@ export default function AgentPanel() {
setCurrentAgentId(undefined); setCurrentAgentId(undefined);
}} }}
disabled={agentQuery.isInitialLoading} disabled={agentQuery.isInitialLoading}
aria-label={
localize('com_ui_create') +
' ' +
localize('com_ui_new') +
' ' +
localize('com_ui_agent')
}
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
{localize('com_ui_create') + {localize('com_ui_create') +

View file

@ -117,6 +117,7 @@ function SwitchItem({
className="ml-4" className="ml-4"
data-testid={id} data-testid={id}
disabled={disabled} disabled={disabled}
aria-label={label}
/> />
</div> </div>
</HoverCard> </HoverCard>

View file

@ -61,6 +61,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
disabled={runCodeIsEnabled ? false : !isToolAuthenticated} disabled={runCodeIsEnabled ? false : !isToolAuthenticated}
aria-label={localize('com_ui_run_code')}
/> />
)} )}
/> />
@ -81,7 +82,11 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
</button> </button>
<div className="ml-2 flex gap-2"> <div className="ml-2 flex gap-2">
{isUserProvided && (isToolAuthenticated || runCodeIsEnabled) && ( {isUserProvided && (isToolAuthenticated || runCodeIsEnabled) && (
<button type="button" onClick={() => setIsDialogOpen(true)}> <button
type="button"
onClick={() => setIsDialogOpen(true)}
aria-label={localize('com_ui_add_api_key')}
>
<KeyRoundIcon className="h-5 w-5 text-text-primary" /> <KeyRoundIcon className="h-5 w-5 text-text-primary" />
</button> </button>
)} )}

View file

@ -104,6 +104,7 @@ export default function ApiKeyDialog({
<Button <Button
onClick={onRevoke} onClick={onRevoke}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80" className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
aria-label={localize('com_ui_revoke')}
> >
{localize('com_ui_revoke')} {localize('com_ui_revoke')}
</Button> </Button>

View file

@ -32,6 +32,7 @@ function FileSearchCheckbox() {
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
aria-label={localize('com_agents_enable_file_search')}
/> />
)} )}
/> />

View file

@ -104,15 +104,16 @@ export function AvatarMenu({
className="flex min-w-[100px] max-w-xs flex-col rounded-xl border border-gray-400 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-850 dark:text-white" className="flex min-w-[100px] max-w-xs flex-col rounded-xl border border-gray-400 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-850 dark:text-white"
sideOffset={5} sideOffset={5}
> >
<div <button
type="button"
role="menuitem" role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5" className="group m-1.5 flex cursor-pointer gap-2 rounded-lg p-2.5 text-sm hover:bg-gray-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:bg-white/5"
tabIndex={-1} tabIndex={0}
data-orientation="vertical" data-orientation="vertical"
onClick={onItemClick} onClick={onItemClick}
> >
{localize('com_ui_upload_image')} {localize('com_ui_upload_image')}
</div> </button>
{/* <Popover.Close {/* <Popover.Close
role="menuitem" role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5" className="group m-1.5 flex cursor-pointer gap-2 rounded p-2.5 text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"

View file

@ -210,10 +210,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
control={control} control={control}
rules={{ required: true }} rules={{ required: true }}
render={({ field }) => ( render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} /> <Checkbox
id="trust-checkbox"
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="trust-label"
/>
)} )}
/> />
<Label htmlFor="trust" className="flex flex-col"> <Label id="trust-label" htmlFor="trust-checkbox" className="flex flex-col">
{localize('com_ui_trust_app')} {localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary"> <span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')} {localize('com_agents_mcp_trust_subtext')}
@ -269,6 +274,10 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
checked={selectedTools.includes(tool)} checked={selectedTools.includes(tool)}
onCheckedChange={() => handleToolToggle(tool)} onCheckedChange={() => handleToolToggle(tool)}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
aria-label={tool
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')}
/> />
<span className="text-token-text-primary"> <span className="text-token-text-primary">
{tool {tool

View file

@ -162,6 +162,12 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
} }
}} }}
tabIndex={isExpanded ? 0 : -1} tabIndex={isExpanded ? 0 : -1}
aria-label={
selectedTools.length === serverInfo.tools?.length &&
selectedTools.length > 0
? localize('com_ui_deselect_all')
: localize('com_ui_select_all')
}
/> />
</div> </div>
@ -252,6 +258,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
className={cn( className={cn(
'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background', 'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
)} )}
aria-label={subTool.metadata.name}
/> />
<span className="text-token-text-primary select-none"> <span className="text-token-text-primary select-none">
{subTool.metadata.name} {subTool.metadata.name}

View file

@ -102,6 +102,7 @@ export default function ModelPanel({
onClick={() => { onClick={() => {
setActivePanel(Panel.builder); setActivePanel(Panel.builder);
}} }}
aria-label={localize('com_ui_back_to_builder')}
> >
<div className="model-panel-content flex w-full items-center justify-center gap-2"> <div className="model-panel-content flex w-full items-center justify-center gap-2">
<ChevronLeft /> <ChevronLeft />

View file

@ -69,6 +69,7 @@ export default function Action({
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
disabled={webSearchIsEnabled ? false : !isToolAuthenticated} disabled={webSearchIsEnabled ? false : !isToolAuthenticated}
aria-label={localize('com_ui_web_search')}
/> />
)} )}
/> />

View file

@ -250,7 +250,11 @@ export default function ApiKeyDialog({
}} }}
buttons={ buttons={
isToolAuthenticated && ( isToolAuthenticated && (
<Button onClick={onRevoke} className="bg-red-500 text-white hover:bg-red-600"> <Button
onClick={onRevoke}
className="bg-red-500 text-white hover:bg-red-600"
aria-label={localize('com_ui_revoke')}
>
{localize('com_ui_revoke')} {localize('com_ui_revoke')}
</Button> </Button>
) )

View file

@ -16,6 +16,7 @@ const VersionButton = ({ setActivePanel }: VersionButtonProps) => {
variant={'outline'} variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium"
onClick={() => setActivePanel(Panel.version)} onClick={() => setActivePanel(Panel.version)}
aria-label={localize('com_ui_agent_version')}
> >
<History className="h-4 w-4 cursor-pointer" aria-hidden="true" /> <History className="h-4 w-4 cursor-pointer" aria-hidden="true" />
{localize('com_ui_agent_version')} {localize('com_ui_agent_version')}

View file

@ -112,6 +112,7 @@ const BookmarkTable = () => {
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full gap-2 text-sm" className="w-full gap-2 text-sm"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
<BookmarkPlusIcon className="size-4" /> <BookmarkPlusIcon className="size-4" />

View file

@ -213,7 +213,11 @@ function Avatar({
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}> <Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<div className="flex w-full items-center justify-center gap-4"> <div className="flex w-full items-center justify-center gap-4">
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button type="button" className="h-20 w-20"> <button
type="button"
className="h-20 w-20"
aria-label={localize('com_ui_upload_avatar_label')}
>
{previewUrl ? <AssistantAvatar url={previewUrl} progress={progress} /> : <NoImage />} {previewUrl ? <AssistantAvatar url={previewUrl} progress={progress} /> : <NoImage />}
</button> </button>
</Popover.Trigger> </Popover.Trigger>

View file

@ -31,6 +31,7 @@ export default function Code({ version }: { version: number | string }) {
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
aria-labelledby={Capabilities.code_interpreter}
/> />
)} )}
/> />
@ -44,6 +45,7 @@ export default function Code({ version }: { version: number | string }) {
} }
> >
<label <label
id={Capabilities.code_interpreter}
className="form-check-label text-token-text-primary w-full cursor-pointer" className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.code_interpreter} htmlFor={Capabilities.code_interpreter}
> >

View file

@ -21,10 +21,12 @@ export default function ImageVision() {
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
aria-labelledby={Capabilities.image_vision}
/> />
)} )}
/> />
<label <label
id={Capabilities.image_vision}
className="form-check-label text-token-text-primary w-full cursor-pointer" className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.image_vision} htmlFor={Capabilities.image_vision}
onClick={() => onClick={() =>

View file

@ -60,11 +60,13 @@ export default function Retrieval({
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field.value.toString()} value={field.value.toString()}
aria-labelledby={Capabilities.retrieval}
/> />
)} )}
/> />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<label <label
id={Capabilities.retrieval}
className={cn( className={cn(
'form-check-label text-token-text-primary w-full select-none', 'form-check-label text-token-text-primary w-full select-none',
isDisabled ? 'cursor-no-drop opacity-50' : 'cursor-pointer', isDisabled ? 'cursor-no-drop opacity-50' : 'cursor-pointer',

Some files were not shown because too many files have changed in this diff Show more