diff --git a/Dockerfile b/Dockerfile index 138ed08f33..cd4e5e63bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.0 +# v0.8.1-rc1 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index 41ff375c9b..08c028dd09 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.0 +# v0.8.1-rc1 # Base for all builds FROM node:20-alpine AS base-min diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js index 032781f1f1..b8bdacf13e 100644 --- a/api/app/clients/OllamaClient.js +++ b/api/app/clients/OllamaClient.js @@ -2,7 +2,7 @@ const { z } = require('zod'); const axios = require('axios'); const { Ollama } = require('ollama'); const { sleep } = require('@librechat/agents'); -const { logAxiosError } = require('@librechat/api'); +const { resolveHeaders } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { Constants } = require('librechat-data-provider'); const { deriveBaseURL } = require('~/utils'); @@ -44,6 +44,7 @@ class OllamaClient { constructor(options = {}) { const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434'); this.streamRate = options.streamRate ?? Constants.DEFAULT_STREAM_RATE; + this.headers = options.headers ?? {}; /** @type {Ollama} */ this.client = new Ollama({ host }); } @@ -51,27 +52,32 @@ class OllamaClient { /** * Fetches Ollama models from the specified base API path. * @param {string} baseURL + * @param {Object} [options] - Optional configuration + * @param {Partial} [options.user] - User object for header resolution + * @param {Record} [options.headers] - Headers to include in the request * @returns {Promise} The Ollama models. + * @throws {Error} Throws if the Ollama API request fails */ - static async fetchModels(baseURL) { - let models = []; + static async fetchModels(baseURL, options = {}) { if (!baseURL) { - return models; - } - try { - const ollamaEndpoint = deriveBaseURL(baseURL); - /** @type {Promise>} */ - 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 []; } + + const ollamaEndpoint = deriveBaseURL(baseURL); + + const resolvedHeaders = resolveHeaders({ + headers: options.headers, + user: options.user, + }); + + /** @type {Promise>} */ + const response = await axios.get(`${ollamaEndpoint}/api/tags`, { + headers: resolvedHeaders, + timeout: 5000, + }); + + const models = response.data.models.map((tag) => tag.name); + return models; } /** diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 9a2a047bb1..35eeb32ffe 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -5,6 +5,7 @@ const FormData = require('form-data'); const { ProxyAgent } = require('undici'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); +const { HttpsProxyAgent } = require('https-proxy-agent'); const { logAxiosError, oaiToolkit } = require('@librechat/api'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -348,16 +349,7 @@ Error Message: ${error.message}`); }; if (process.env.PROXY) { - try { - 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); - } + axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY); } if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { diff --git a/api/models/Agent.js b/api/models/Agent.js index 5468293523..f5f740ba7b 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -62,25 +62,37 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} 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 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} */ const ephemeralAgent = req.body.ephemeralAgent; const mcpServers = new Set(ephemeralAgent?.mcp); + if (modelSpec?.mcpServers) { + for (const mcpServer of modelSpec.mcpServers) { + mcpServers.add(mcpServer); + } + } /** @type {string[]} */ const tools = []; - if (ephemeralAgent?.execute_code === true) { + if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) { tools.push(Tools.execute_code); } - if (ephemeralAgent?.file_search === true) { + if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) { tools.push(Tools.file_search); } - if (ephemeralAgent?.web_search === true) { + if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) { tools.push(Tools.web_search); } @@ -122,17 +134,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _ * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} 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) { return null; } 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({ id: agent_id, diff --git a/api/models/tx.js b/api/models/tx.js index 462396d860..92f2432d0e 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -1,4 +1,4 @@ -const { matchModelName } = require('@librechat/api'); +const { matchModelName, findMatchingPattern } = require('@librechat/api'); const defaultRate = 6; /** @@ -6,44 +6,58 @@ const defaultRate = 6; * source: https://aws.amazon.com/bedrock/pricing/ * */ 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: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-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-70b': { prompt: 0.72, completion: 0.72 }, '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-3b': { prompt: 0.15, completion: 0.15 }, 'llama3-2-11b': { prompt: 0.16, completion: 0.16 }, '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:70b': { prompt: 0.72, completion: 0.72 }, '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:3b': { prompt: 0.15, completion: 0.15 }, 'llama3.2:11b': { prompt: 0.16, completion: 0.16 }, '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-70b': { prompt: 0.72, completion: 0.72 }, '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-3b': { prompt: 0.15, completion: 0.15 }, 'llama-3.2-11b': { prompt: 0.16, completion: 0.16 }, '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 }, 'mistral-7b': { 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 }, 'command-text': { prompt: 1.5, completion: 2.0 }, 'command-light': { prompt: 0.3, completion: 0.6 }, - 'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 }, - 'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 }, - 'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 }, - 'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 }, - 'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 }, - 'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 }, - 'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 }, - 'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 }, - 'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 }, + // AI21 models + 'j2-mid': { prompt: 12.5, completion: 12.5 }, + 'j2-ultra': { prompt: 18.8, completion: 18.8 }, + 'jamba-instruct': { prompt: 0.5, completion: 0.7 }, + // Amazon Titan models + 'titan-text-lite': { prompt: 0.15, completion: 0.2 }, + 'titan-text-express': { prompt: 0.2, completion: 0.6 }, + 'titan-text-premier': { prompt: 0.5, completion: 1.5 }, + // 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 }, }; @@ -71,100 +89,136 @@ const bedrockValues = { */ const tokenValues = Object.assign( { + // Legacy token size mappings (generic patterns - check LAST) '8k': { prompt: 30, completion: 60 }, '32k': { prompt: 60, completion: 120 }, '4k': { prompt: 1.5, completion: 2 }, '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 }, - 'o4-mini': { prompt: 1.1, completion: 4.4 }, - 'o3-mini': { prompt: 1.1, completion: 4.4 }, - o3: { prompt: 2, completion: 8 }, - 'o1-mini': { prompt: 1.1, completion: 4.4 }, - 'o1-preview': { prompt: 15, completion: 60 }, - o1: { prompt: 15, completion: 60 }, + 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, + 'gpt-4-1106': { prompt: 10, completion: 30 }, + 'gpt-4.1': { prompt: 2, completion: 8 }, 'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 }, '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-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-2024-05-13': { prompt: 5, completion: 15 }, - 'gpt-4-1106': { prompt: 10, completion: 30 }, - 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, - 'claude-3-opus': { prompt: 15, completion: 75 }, + 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, + 'gpt-5': { prompt: 1.25, completion: 10 }, + '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-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-7-sonnet': { prompt: 3, completion: 15 }, 'claude-3.7-sonnet': { prompt: 3, completion: 15 }, - 'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, - '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-haiku-4-5': { prompt: 1, completion: 5 }, 'claude-opus-4': { prompt: 15, completion: 75 }, - 'claude-2.1': { prompt: 8, completion: 24 }, - '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 }, + 'claude-sonnet-4': { prompt: 3, completion: 15 }, '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: { prompt: 0.28, completion: 0.42 }, - /* cohere doesn't have rates for the older command models, - so this was from https://artificialanalysis.ai/models/command-light/providers */ - command: { prompt: 0.38, completion: 0.38 }, - gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, + 'deepseek-r1': { prompt: 0.4, completion: 2.0 }, + 'deepseek-v3': { prompt: 0.2, completion: 0.8 }, + 'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing) + 'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing) + 'gemma-3-27b': { prompt: 0.09, completion: 0.16 }, + 'gemini-1.5': { prompt: 2.5, completion: 10 }, + 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, + 'gemini-1.5-flash-8b': { 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': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, + 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, + '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-lite': { prompt: 0.1, completion: 0.4 }, - 'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time - '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-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, - gemini: { prompt: 0.5, completion: 1.5 }, - 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 + 'grok-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-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2': { prompt: 2.0, completion: 10.0 }, - 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, - 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, - 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, + 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-latest': { prompt: 2.0, completion: 10.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-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 }, - 'ministral-8b': { prompt: 0.1, completion: 0.1 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 }, - // GPT-OSS models - 'gpt-oss': { prompt: 0.05, completion: 0.2 }, + 'ministral-8b': { prompt: 0.1, completion: 0.1 }, + '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: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 }, 'glm-4': { 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.5v': { prompt: 0.6, completion: 1.8 }, '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 }, + // 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, ); @@ -195,67 +249,39 @@ const cacheTokenValues = { * @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found. */ 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); if (!modelName) { return undefined; } + // Legacy token size mappings and aliases for older models if (modelName.includes('gpt-3.5-turbo-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')) { 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')) { - return 'gpt-4-1106'; - } else if (modelName.includes('gpt-4-1106')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-vision } 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')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-turbo } else if (modelName.includes('gpt-4-32k')) { return '32k'; } else if (modelName.includes('gpt-4')) { return '8k'; - } else if (tokenValues[modelName]) { - return modelName; } return undefined; diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 3cbce34295..670ea9d5ec 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -1,3 +1,4 @@ +const { maxTokensMap } = require('@librechat/api'); const { EModelEndpoint } = require('librechat-data-provider'); const { defaultRate, @@ -113,6 +114,14 @@ describe('getValueKey', () => { 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"', () => { expect(getValueKey('gpt-4o-2024-08-06')).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', () => { const valueKey = getValueKey('gpt-4o-2024-08-06'); 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', () => { 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', () => { it('should return the correct cache multiplier for a given valueKey and cacheType', () => { 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', () => { const modelVariations = [ '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([]); + }); +}); diff --git a/api/package.json b/api/package.json index f0b654b1af..977cd13668 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.0", + "version": "v0.8.1-rc1", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.90", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 249817610e..096727e977 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -116,11 +116,15 @@ const refreshController = async (req, res) => { const token = await setAuthTokens(userId, res, session); // trigger OAuth MCP server reconnection asynchronously (best effort) - void getOAuthReconnectionManager() - .reconnectServers(userId) - .catch((err) => { - logger.error('Error reconnecting OAuth MCP servers:', err); - }); + try { + void getOAuthReconnectionManager() + .reconnectServers(userId) + .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 }); } else if (req?.query?.retry) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a648488d14..27da7d5cc1 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -8,6 +8,7 @@ const { Tokenizer, checkAccess, logAxiosError, + sanitizeTitle, resolveHeaders, getBalanceConfig, memoryInstructions, @@ -775,6 +776,7 @@ class AgentClient extends BaseClient { const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents]; config = { + runName: 'AgentRun', configurable: { thread_id: this.conversationId, last_agent_index: this.agentConfigs?.size ?? 0, @@ -1233,6 +1235,10 @@ class AgentClient extends BaseClient { 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) { logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err); return; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 2f6c60031e..524363e190 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({ }), })); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), +})); + describe('AgentClient - titleConvo', () => { let client; let mockRun; @@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => { expect(result).toBe('Generated Title'); }); + it('should sanitize the generated title by removing think blocks', async () => { + const titleWithThinkBlock = 'reasoning about the title 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 block and return only the clean title + expect(result).toBe('User Hi Greeting'); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('should return fallback title when sanitization results in empty string', async () => { + const titleOnlyThinkBlock = 'only reasoning no actual title'; + 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 () => { mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed')); diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 840d957fa1..65cbcee4ca 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -57,7 +57,7 @@ async function loadConfigModels(req) { for (let i = 0; i < customEndpoints.length; 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); endpointsMap[name] = endpoint; @@ -76,6 +76,8 @@ async function loadConfigModels(req) { apiKey: API_KEY, baseURL: BASE_URL, user: req.user.id, + userObject: req.user, + headers: endpointHeaders, direct: endpoint.directEndpoint, userIdQuery: models.userIdQuery, }); diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 1966834ed4..be3f3bf4f9 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -134,16 +134,16 @@ const initializeAgent = async ({ }); const tokensModel = - agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model; - const maxTokens = optionalChainWithEmptyCheck( - modelOptions.maxOutputTokens, - modelOptions.maxTokens, + agent.provider === EModelEndpoint.azureOpenAI ? agent.model : options.llmConfig?.model; + const maxOutputTokens = optionalChainWithEmptyCheck( + options.llmConfig?.maxOutputTokens, + options.llmConfig?.maxTokens, 0, ); const agentMaxContextTokens = optionalChainWithEmptyCheck( maxContextTokens, getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig), - 4096, + 18000, ); if ( @@ -203,7 +203,7 @@ const initializeAgent = async ({ userMCPAuthMap, toolContextMap, useLegacyContent: !!options.useLegacyContent, - maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9), + maxContextTokens: Math.round((agentMaxContextTokens - maxOutputTokens) * 0.9), }; }; diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 3bf90e8d82..34fcaf4be4 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat- const { loadAgent } = require('~/models/Agent'); 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({ req, + spec, agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, endpoint, model_parameters, @@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { endpoint, agent_id, endpointType, - instructions, model_parameters, agent: agentPromise, }); diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index e2a092ad55..066c9430ce 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -1,4 +1,3 @@ -const { Providers } = require('@librechat/agents'); const { resolveHeaders, isUserProvided, @@ -143,39 +142,27 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid if (optionsOnly) { const modelOptions = endpointOption?.model_parameters ?? {}; - if (endpoint !== Providers.OLLAMA) { - clientOptions = Object.assign( - { - modelOptions, - }, - clientOptions, - ); - clientOptions.modelOptions.user = req.user.id; - const options = getOpenAIConfig(apiKey, clientOptions, endpoint); - if (options != null) { - options.useLegacyContent = true; - options.endpointTokenConfig = endpointTokenConfig; - } - if (!clientOptions.streamRate) { - return options; - } - options.llmConfig.callbacks = [ - { - handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate), - }, - ]; + clientOptions = Object.assign( + { + modelOptions, + }, + clientOptions, + ); + clientOptions.modelOptions.user = req.user.id; + const options = getOpenAIConfig(apiKey, clientOptions, endpoint); + if (options != null) { + options.useLegacyContent = true; + options.endpointTokenConfig = endpointTokenConfig; + } + if (!clientOptions.streamRate) { return options; } - - if (clientOptions.reverseProxyUrl) { - modelOptions.baseUrl = clientOptions.reverseProxyUrl.split('/v1')[0]; - delete clientOptions.reverseProxyUrl; - } - - return { - useLegacyContent: true, - llmConfig: modelOptions, - }; + options.llmConfig.callbacks = [ + { + handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate), + }, + ]; + return options; } const client = new OpenAIClient(apiKey, clientOptions); diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index 8b4b2d81b6..ecc3671db3 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -159,7 +159,7 @@ const initializeClient = async ({ modelOptions.model = modelName; clientOptions = Object.assign({ modelOptions }, clientOptions); clientOptions.modelOptions.user = req.user.id; - const options = getOpenAIConfig(apiKey, clientOptions); + const options = getOpenAIConfig(apiKey, clientOptions, endpoint); if (options != null && serverless === true) { options.useLegacyContent = true; } diff --git a/api/server/services/Files/Audio/getCustomConfigSpeech.js b/api/server/services/Files/Audio/getCustomConfigSpeech.js index b4bc8f704f..d0d0b51ac2 100644 --- a/api/server/services/Files/Audio/getCustomConfigSpeech.js +++ b/api/server/services/Files/Audio/getCustomConfigSpeech.js @@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) { settings.advancedMode = speechTab.advancedMode; } - if (speechTab.speechToText) { - for (const key in speechTab.speechToText) { - if (speechTab.speechToText[key] !== undefined) { - settings[key] = speechTab.speechToText[key]; + if (speechTab.speechToText !== undefined) { + if (typeof speechTab.speechToText === 'boolean') { + settings.speechToText = speechTab.speechToText; + } else { + for (const key in speechTab.speechToText) { + if (speechTab.speechToText[key] !== undefined) { + settings[key] = speechTab.speechToText[key]; + } } } } - if (speechTab.textToSpeech) { - for (const key in speechTab.textToSpeech) { - if (speechTab.textToSpeech[key] !== undefined) { - settings[key] = speechTab.textToSpeech[key]; + if (speechTab.textToSpeech !== undefined) { + if (typeof speechTab.textToSpeech === 'boolean') { + settings.textToSpeech = speechTab.textToSpeech; + } else { + for (const key in speechTab.textToSpeech) { + if (speechTab.textToSpeech[key] !== undefined) { + settings[key] = speechTab.textToSpeech[key]; + } } } } diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 5e945f0e36..701412523d 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -598,11 +598,22 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { throw new Error('OCR capability is not enabled for Agents'); } else if (shouldUseOCR) { - 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 }); + try { + 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 }); + } catch (ocrError) { + logger.error( + `[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`, + ocrError, + ); + } } const shouldUseSTT = fileConfig.checkType( diff --git a/api/server/services/GraphApiService.js b/api/server/services/GraphApiService.js index 82fa245d58..08ca253964 100644 --- a/api/server/services/GraphApiService.js +++ b/api/server/services/GraphApiService.js @@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li /** * 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} sub - Subject identifier * @returns {Promise>} Array of group ID strings (GUIDs) @@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li const getUserEntraGroups = async (accessToken, sub) => { try { 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(); - - return (groupsResponse.value || []).map((group) => group.id); + const groupIds = Array.isArray(response?.value) ? response.value : []; + return [...new Set(groupIds.map((groupId) => String(groupId)))]; } catch (error) { logger.error('[getUserEntraGroups] Error fetching user groups:', error); return []; @@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => { const getUserOwnedEntraGroups = async (accessToken, sub) => { try { const graphClient = await createGraphClient(accessToken, sub); + const allGroupIds = []; + let nextLink = '/me/ownedObjects/microsoft.graph.group'; - const groupsResponse = await graphClient - .api('/me/ownedObjects/microsoft.graph.group') - .select('id') - .get(); + while (nextLink) { + const response = await graphClient.api(nextLink).select('id').top(999).get(); + const groups = response?.value || []; + 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) { logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); return []; @@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => { const getGroupMembers = async (accessToken, sub, groupId) => { try { const graphClient = await createGraphClient(accessToken, sub); - const allMembers = []; - let nextLink = `/groups/${groupId}/members`; + const allMembers = new Set(); + let nextLink = `/groups/${groupId}/transitiveMembers`; while (nextLink) { const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); - const members = membersResponse.value || []; - allMembers.push(...members.map((member) => member.id)); + const members = membersResponse?.value || []; + members.forEach((member) => { + if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') { + allMembers.add(member.id); + } + }); nextLink = membersResponse['@odata.nextLink'] - ? membersResponse['@odata.nextLink'].split('/v1.0')[1] + ? membersResponse['@odata.nextLink'] + .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') + .trim() || null : null; } - return allMembers; + return Array.from(allMembers); } catch (error) { logger.error('[getGroupMembers] Error fetching group members:', error); return []; diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js index 5d8dd62cf5..fa11190cc3 100644 --- a/api/server/services/GraphApiService.spec.js +++ b/api/server/services/GraphApiService.spec.js @@ -73,6 +73,7 @@ describe('GraphApiService', () => { header: jest.fn().mockReturnThis(), top: jest.fn().mockReturnThis(), get: jest.fn(), + post: jest.fn(), }; Client.init.mockReturnValue(mockGraphClient); @@ -514,31 +515,33 @@ describe('GraphApiService', () => { }); describe('getUserEntraGroups', () => { - it('should fetch user groups from memberOf endpoint', async () => { + it('should fetch user groups using getMemberGroups endpoint', async () => { const mockGroupsResponse = { - value: [ - { - id: 'group-1', - }, - { - id: 'group-2', - }, - ], + value: ['group-1', 'group-2'], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); - expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf'); - expect(mockGraphClient.select).toHaveBeenCalledWith('id'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups'); + 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']); }); 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'); @@ -550,7 +553,7 @@ describe('GraphApiService', () => { value: [], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -558,7 +561,7 @@ describe('GraphApiService', () => { }); it('should handle missing value property', async () => { - mockGraphClient.get.mockResolvedValue({}); + mockGraphClient.post.mockResolvedValue({}); 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', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 10b08c99ac..6cbc018824 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -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.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 {Record} [params.headers] - Optional headers for the request. + * @param {Partial} [params.userObject] - Optional user object for header resolution. * @returns {Promise} A promise that resolves to an array of model identifiers. * @async */ @@ -52,6 +54,8 @@ const fetchModels = async ({ userIdQuery = false, createTokenConfig = true, tokenKey, + headers, + userObject, }) => { let models = []; const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL; @@ -65,7 +69,13 @@ const fetchModels = async ({ } 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 { diff --git a/api/server/services/ModelService.spec.js b/api/server/services/ModelService.spec.js index d193b65f4f..81c1203461 100644 --- a/api/server/services/ModelService.spec.js +++ b/api/server/services/ModelService.spec.js @@ -1,5 +1,5 @@ const axios = require('axios'); -const { logger } = require('@librechat/data-schemas'); +const { logAxiosError, resolveHeaders } = require('@librechat/api'); const { EModelEndpoint, defaultModels } = require('librechat-data-provider'); const { @@ -18,6 +18,8 @@ jest.mock('@librechat/api', () => { processModelData: jest.fn((...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(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', { + headers: {}, timeout: 5000, }); }); - it('should handle errors gracefully when fetching Ollama models fails', async () => { - axios.get.mockRejectedValue(new Error('Network error')); + it('should pass headers and user object to Ollama fetchModels', async () => { + 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({ user: 'user789', apiKey: 'testApiKey', @@ -290,8 +331,13 @@ describe('fetchModels with Ollama specific logic', () => { name: 'OllamaAPI', }); - expect(models).toEqual([]); - expect(logger.error).toHaveBeenCalled(); + expect(models).toEqual(['fallback-model-1', 'fallback-model-2']); + 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 () => { diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 079bed9e10..26143b226a 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -357,16 +357,18 @@ async function setupOpenId() { }; 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( - `[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' }); } const result = await findOpenIDUser({ findUser, - email: claims.email, + email: email, openidId: claims.sub, idOnTheSource: claims.oid, strategyName: 'openidStrategy', @@ -433,7 +435,7 @@ async function setupOpenId() { provider: 'openid', openidId: userinfo.sub, username, - email: userinfo.email || '', + email: email || '', emailVerified: userinfo.email_verified || false, name: fullName, idOnTheSource: userinfo.oid, @@ -447,8 +449,8 @@ async function setupOpenId() { user.username = username; user.name = fullName; user.idOnTheSource = userinfo.oid; - if (userinfo.email && userinfo.email !== user.email) { - user.email = userinfo.email; + if (email && email !== user.email) { + user.email = email; user.emailVerified = userinfo.email_verified || false; } } diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 162827767f..12daf64e47 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -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', () => { const models = [ 'claude-2.1', @@ -469,7 +482,7 @@ describe('getModelMaxTokens', () => { test('should return correct max output tokens for GPT-5 models', () => { 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, EModelEndpoint.openAI)).toBe( maxOutputTokensMap[EModelEndpoint.openAI][model], @@ -582,6 +595,13 @@ describe('matchModelName', () => { 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 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'); @@ -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', () => { const modelVariations = [ '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('getModelMaxTokens', () => { test('should return correct tokens for GLM models', () => { diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 654eed722d..bdb7cd8ff3 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.0 */ +/** v0.8.1-rc1 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index b46f77cbd8..e560b14ef4 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.0", + "version": "v0.8.1-rc1", "description": "", "type": "module", "scripts": { @@ -149,7 +149,7 @@ "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", "typescript": "^5.3.3", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-compression2": "^2.2.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-pwa": "^0.21.2" diff --git a/client/src/App.jsx b/client/src/App.jsx index decad9392b..eda775bc71 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { RecoilRoot } from 'recoil'; import { DndProvider } from 'react-dnd'; 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 { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; +import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; import { router } from './routes'; @@ -24,6 +26,10 @@ const App = () => { }), }); + useEffect(() => { + initializeFontSize(); + }, []); + // Load theme from environment variables if available const envTheme = getThemeFromEnv(); diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 3925492534..4effd7d679 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !isEphemeralAgent(agent_id), }); - const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, { - enabled: !isEphemeralAgent(agent_id), - }); + const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); const { data: mcpData } = useMCPToolsQuery({ enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null, diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 3cbfe330ca..ef77734e30 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -11,9 +11,9 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { renderAgentAvatar, clearMessagesCache } from '~/utils'; import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; -import { renderAgentAvatar } from '~/utils'; interface SupportContact { name?: string; @@ -56,10 +56,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id); - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); /** Template with agent configuration */ diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 97cf1b20cc..ef882142e2 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; 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 { ContextType } from '~/common'; import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks'; @@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings'; import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelGroup } from '~/components/SidePanel'; import { OpenSidebar } from '~/components/Chat/Menus'; +import { cn, clearMessagesCache } from '~/utils'; import CategoryTabs from './CategoryTabs'; import AgentDetail from './AgentDetail'; import SearchBar from './SearchBar'; import AgentGrid from './AgentGrid'; -import { cn } from '~/utils'; import store from '~/store'; interface AgentMarketplaceProps { @@ -224,10 +224,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index fa5fa34fbc..e09f168afe 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -58,6 +58,7 @@ const LabelController: React.FC = ({ checked={field.value} onCheckedChange={field.onChange} value={field.value.toString()} + aria-label={label} /> )} /> diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 1efb239308..1e1b7d1e4b 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => { // Performance check: rendering should be fast const renderTime = endTime - startTime; - expect(renderTime).toBeLessThan(720); + expect(renderTime).toBeLessThan(740); console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); diff --git a/client/src/components/Bookmarks/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm.tsx index 3b2633485b..23e94dbfb1 100644 --- a/client/src/components/Bookmarks/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm.tsx @@ -129,7 +129,11 @@ const BookmarkForm = ({
-
{conversationId != null && conversationId && ( @@ -161,6 +166,7 @@ const BookmarkForm = ({ onCheckedChange={field.onChange} className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" value={field.value?.toString()} + aria-label={localize('com_ui_bookmarks_add_to_conversation')} /> )} /> diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 0736c7dc61..f1dc1ef076 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -12,6 +12,7 @@ import { import { useTextarea, useAutoSave, + useLocalize, useRequiresKey, useHandleKeyUp, useQueryParams, @@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); useFocusChatEffect(textAreaRef); + const localize = useLocalize(); const [isCollapsed, setIsCollapsed] = useState(false); const [, setIsScrollable] = useState(false); @@ -220,6 +222,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( { )} {showMentionPopover && ( { setIsTextAreaFocused(true); }} onBlur={setIsTextAreaFocused.bind(null, false)} + aria-label={localize('com_ui_message_input')} onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( diff --git a/client/src/components/Chat/Input/Files/FileUpload.tsx b/client/src/components/Chat/Input/Files/FileUpload.tsx index 723fa32e86..718c8c1f5d 100644 --- a/client/src/components/Chat/Input/Files/FileUpload.tsx +++ b/client/src/components/Chat/Input/Files/FileUpload.tsx @@ -62,17 +62,28 @@ const FileUpload: React.FC = ({ statusText = invalidText ?? localize('com_ui_upload_invalid'); } + const handleClick = () => { + const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + }; + return ( - + ); }; diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index ffb3e2825b..70459b2d66 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -122,7 +122,11 @@ export default function DataTable({ columns, data }: DataTablePro /> - diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx index e3472a2aa0..2defcc7623 100644 --- a/client/src/components/Chat/Input/Mention.tsx +++ b/client/src/components/Chat/Input/Mention.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react'; import { useCombobox } from '@librechat/client'; import { AutoSizer, List } from 'react-virtualized'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import type { MentionOption, ConvoGenerator } from '~/common'; import type { SetterOrUpdater } from 'recoil'; import useSelectMention from '~/hooks/Input/useSelectMention'; @@ -14,6 +15,7 @@ import MentionItem from './MentionItem'; const ROW_HEIGHT = 40; export default function Mention({ + conversation, setShowMentionPopover, newConversation, textAreaRef, @@ -21,6 +23,7 @@ export default function Mention({ placeholder = 'com_ui_mention', includeAssistants = true, }: { + conversation: TConversation | null; setShowMentionPopover: SetterOrUpdater; newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; @@ -42,6 +45,7 @@ export default function Mention({ const { onSelectMention } = useSelectMention({ presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx index bd639523d8..eac3bb200c 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx @@ -1,5 +1,5 @@ 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'; interface ModelSelectorChatContextValue { @@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue { spec?: string | null; agent_id?: string | null; assistant_id?: string | null; + conversation: TConversation | null; newConversation: ReturnType['newConversation']; } @@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN spec: conversation?.spec, agent_id: conversation?.agent_id, assistant_id: conversation?.assistant_id, + conversation, newConversation, }), - [ - conversation?.endpoint, - conversation?.model, - conversation?.spec, - conversation?.agent_id, - conversation?.assistant_id, - newConversation, - ], + [conversation, newConversation], ); return ( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index a4527d56e7..e79d9a2d21 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const agentsMap = useAgentsMapContext(); const assistantsMap = useAssistantsMapContext(); const { data: endpointsConfig } = useGetEndpointsQuery(); - const { endpoint, model, spec, agent_id, assistant_id, newConversation } = + const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } = useModelSelectorChatContext(); const modelSpecs = useMemo(() => { const specs = startupConfig?.modelSpecs?.list ?? []; @@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const { onSelectEndpoint, onSelectSpec } = useSelectMention({ // presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/components/Chat/Menus/HeaderNewChat.tsx b/client/src/components/Chat/Menus/HeaderNewChat.tsx index b2dc6416ab..5245ccbf13 100644 --- a/client/src/components/Chat/Menus/HeaderNewChat.tsx +++ b/client/src/components/Chat/Menus/HeaderNewChat.tsx @@ -1,8 +1,8 @@ +import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, Constants } from 'librechat-data-provider'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; -import type { TMessage } from 'librechat-data-provider'; import { useChatContext } from '~/Providers'; +import { clearMessagesCache } from '~/utils'; import { useLocalize } from '~/hooks'; export default function HeaderNewChat() { @@ -15,10 +15,7 @@ export default function HeaderNewChat() { window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index 4e7710e0a7..a0c65bc04c 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -59,9 +59,10 @@ const PresetItems: FC<{ - +
diff --git a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx index 242b13765e..5422d9733d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx @@ -170,6 +170,7 @@ const EditTextPart = ({ 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', removeFocusRings, )} + aria-label={localize('com_ui_editable_message')} dir={isRTL ? 'rtl' : 'ltr'} /> diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index a79f0985d9..a993009915 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; +import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; @@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) { regenerateMessage, } = useMessageHelpers(props); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { children, messageId = null, isCreatedByUser } = message ?? {}; diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 01459203f0..bea6554ff1 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { CSSTransition } from 'react-transition-group'; import type { TMessage } from 'librechat-data-provider'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { MessagesViewProvider } from '~/Providers'; +import { fontSizeAtom } from '~/store/fontSize'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; import store from '~/store'; @@ -15,7 +17,7 @@ function MessagesViewContent({ messagesTree?: TMessage[] | null; }) { const localize = useLocalize(); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { screenshotTargetRef } = useScreenshot(); const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index c7ac2c69c3..982aee06ce 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useAuthContext, useLocalize } from '~/hooks'; import type { TMessageProps, TMessageIcon } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import Icon from '~/components/Chat/Messages/MessageIcon'; import SearchContent from './Content/SearchContent'; +import { fontSizeAtom } from '~/store/fontSize'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; import { cn } from '~/utils'; @@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( ); export default function SearchMessage({ message }: Pick) { + const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const fontSize = useRecoilValue(store.fontSize); const { user } = useAuthContext(); const localize = useLocalize(); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index f056fccc98..179da5942d 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { type TMessage } from 'librechat-data-provider'; 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 { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useMessageActions } from '~/hooks'; import { cn, logger } from '~/utils'; @@ -58,8 +60,8 @@ const MessageRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const hasNoChildren = !(msg?.children?.length ?? 0); diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b16c6458c7..b6a7032e9f 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -201,7 +201,6 @@ const Conversations: FC = ({ overscanRowCount={10} className="outline-none" style={{ outline: 'none' }} - role="list" aria-label="Conversations" onRowsRendered={handleRowsRendered} tabIndex={-1} diff --git a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx index 3c34cb8c3c..185556ab72 100644 --- a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -82,7 +82,7 @@ export function DeleteConversationDialog({ {localize('com_ui_delete_conversation')} -
+
{localize('com_ui_delete_confirm')} {title} ?
diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 46310268f0..cbbb612251 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -77,7 +77,13 @@ export default function ShareButton({
{showQR && (
- +
)} @@ -87,6 +93,7 @@ export default function ShareButton({ diff --git a/client/src/components/Endpoints/Settings/Advanced.tsx b/client/src/components/Endpoints/Settings/Advanced.tsx index d0beaa9020..504e6cd94d 100644 --- a/client/src/components/Endpoints/Settings/Advanced.tsx +++ b/client/src/components/Endpoints/Settings/Advanced.tsx @@ -151,6 +151,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -160,7 +161,9 @@ export default function Settings({
@@ -199,7 +203,9 @@ export default function Settings({
@@ -238,7 +245,9 @@ export default function Settings({
@@ -306,6 +316,7 @@ export default function Settings({ onCheckedChange={(checked: boolean) => setResendFiles(checked)} disabled={readonly} className="flex" + aria-label={localize('com_endpoint_plug_resend_files')} /> @@ -323,6 +334,7 @@ export default function Settings({ max={2} min={0} step={1} + aria-label={localize('com_endpoint_plug_image_detail')} /> diff --git a/client/src/components/Endpoints/Settings/AgentSettings.tsx b/client/src/components/Endpoints/Settings/AgentSettings.tsx index f41a8bc19e..f4425a4db4 100644 --- a/client/src/components/Endpoints/Settings/AgentSettings.tsx +++ b/client/src/components/Endpoints/Settings/AgentSettings.tsx @@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
@@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeAgent} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_use_functions')} /> @@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeSkip} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_skip_completion')} /> diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index 6e513c1791..18bf95a1d0 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.temperature.min} step={google.temperature.step} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topP.min} step={google.topP.step} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topK.min} step={google.topK.step} className="flex h-4 w-full" + aria-labelledby="top-k-int" /> @@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.maxOutputTokens.min} step={google.maxOutputTokens.step} className="flex h-4 w-full" + aria-labelledby="max-tokens-int" /> @@ -296,6 +297,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -337,6 +339,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="freq-penalty-int" /> @@ -378,6 +381,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="pres-penalty-int" /> diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index ce88687d23..565dadde11 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -1,5 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; 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 { useAttachments, useMessageActions } from '~/hooks'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { cn, logger } from '~/utils'; import store from '~/store'; @@ -60,8 +62,8 @@ const ContentRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const isLast = useMemo( diff --git a/client/src/components/Nav/ExportConversation/ExportModal.tsx b/client/src/components/Nav/ExportConversation/ExportModal.tsx index 642b5bbc81..2083ddec1a 100644 --- a/client/src/components/Nav/ExportConversation/ExportModal.tsx +++ b/client/src/components/Nav/ExportConversation/ExportModal.tsx @@ -124,13 +124,15 @@ export default function ExportModal({ disabled={!exportOptionsSupport} checked={includeOptions} onCheckedChange={setIncludeOptions} + aria-labelledby="includeOptions-label" />
@@ -146,13 +148,15 @@ export default function ExportModal({ disabled={!exportBranchesSupport} checked={exportBranches} onCheckedChange={setExportBranches} + aria-labelledby="exportBranches-label" />
@@ -163,8 +167,14 @@ export default function ExportModal({ {localize('com_nav_export_recursive_or_sequential')}
- +
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx index 02a5ee256e..949453cb5c 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx @@ -30,6 +30,7 @@ export default function SaveDraft({ onCheckedChange={handleCheckedChange} className="ml-4" data-testid="showThinking" + aria-label={localize('com_nav_show_thinking')} />
); diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index 2d06b74392..816c5a2deb 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks'; import { cn, logger } from '~/utils'; function ImportConversations() { - const queryClient = useQueryClient(); - const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); const localize = useLocalize(); - const fileInputRef = useRef(null); + const queryClient = useQueryClient(); const { showToast } = useToastContext(); - + const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const handleSuccess = useCallback(() => { @@ -53,7 +51,8 @@ function ImportConversations() { const handleFileUpload = useCallback( async (file: File) => { try { - const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); + const maxFileSize = startupConfig?.conversationImportMaxFileSize; if (maxFileSize && file.size > maxFileSize) { const size = (maxFileSize / (1024 * 1024)).toFixed(2); showToast({ @@ -76,7 +75,7 @@ function ImportConversations() { }); } }, - [uploadFile, showToast, localize, startupConfig], + [uploadFile, showToast, localize, queryClient], ); const handleFileChange = useCallback( diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index ae25223a9b..bcc6a4af9c 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -13,7 +13,6 @@ import { useMediaQuery, OGDialogHeader, OGDialogTitle, - TooltipAnchor, DataTable, Spinner, Button, @@ -246,37 +245,27 @@ export default function SharedLinks() { }, cell: ({ row }) => (
- { - window.open(`/c/${row.original.conversationId}`, '_blank'); - }} - title={localize('com_ui_view_source')} - > - - - } - /> - { - setDeleteRow(row.original); - setIsDeleteOpen(true); - }} - title={localize('com_ui_delete')} - > - - - } - /> + +
), }, diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 6f1580800e..7b25db721c 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -53,6 +53,7 @@ const LabelController: React.FC = ({ } }} value={field.value.toString()} + aria-label={label} /> )} /> @@ -216,7 +217,12 @@ const AdminSettings = () => { ))}
-
diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx index 17c82c648d..64d6bd60ec 100644 --- a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx +++ b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx @@ -28,7 +28,7 @@ export default function AlwaysMakeProd({ checked={alwaysMakeProd} onCheckedChange={handleCheckedChange} data-testid="alwaysMakeProd" - aria-label="Always make prompt production" + aria-label={localize('com_nav_always_make_prod')} />
{localize('com_nav_always_make_prod')}
diff --git a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx index 430506a748..182580a49c 100644 --- a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx +++ b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx @@ -30,7 +30,7 @@ export default function AutoSendPrompt({ >
{localize('com_nav_auto_send_prompts')}
{ + e.stopPropagation(); + }} className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" >