diff --git a/.env.example b/.env.example index 5004bce94d9..c860eadb538 100644 --- a/.env.example +++ b/.env.example @@ -175,7 +175,7 @@ GOOGLE_KEY=user_provided #============# OPENAI_API_KEY=user_provided -# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k +# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k DEBUG_OPENAI=false @@ -209,12 +209,6 @@ ASSISTANTS_API_KEY=user_provided # More info, including how to enable use of Assistants with Azure here: # https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure -#============# -# OpenRouter # -#============# -# !!!Warning: Use the variable above instead of this one. Using this one will override the OpenAI endpoint -# OPENROUTER_API_KEY= - #============# # Plugins # #============# @@ -254,6 +248,13 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= # DALLE3_AZURE_API_VERSION= # DALLE2_AZURE_API_VERSION= +# Flux +#----------------- +FLUX_API_BASE_URL=https://api.us1.bfl.ai +# FLUX_API_BASE_URL = 'https://api.bfl.ml'; + +# Get your API key at https://api.us1.bfl.ai/auth/profile +# FLUX_API_KEY= # Google #----------------- diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 5bc3d3b2db1..b7bccecae84 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -39,6 +39,9 @@ jobs: - name: Install MCP Package run: npm run build:mcp + - name: Install Data Schemas Package + run: npm run build:data-schemas + - name: Create empty auth.json file run: | mkdir -p api/data @@ -61,4 +64,7 @@ jobs: run: cd api && npm run test:ci - name: Run librechat-data-provider unit tests - run: cd packages/data-provider && npm run test:ci \ No newline at end of file + run: cd packages/data-provider && npm run test:ci + + - name: Run librechat-mcp unit tests + run: cd packages/mcp && npm run test:ci \ No newline at end of file diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml new file mode 100644 index 00000000000..fee72fbe026 --- /dev/null +++ b/.github/workflows/data-schemas.yml @@ -0,0 +1,58 @@ +name: Publish `@librechat/data-schemas` to NPM + +on: + push: + branches: + - main + paths: + - 'packages/data-schemas/package.json' + workflow_dispatch: + inputs: + reason: + description: 'Reason for manual trigger' + required: false + default: 'Manual publish requested' + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + + - name: Install dependencies + run: cd packages/data-schemas && npm ci + + - name: Build + run: cd packages/data-schemas && npm run build + + - name: Set up npm authentication + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc + + - name: Check version change + id: check + working-directory: packages/data-schemas + run: | + PACKAGE_VERSION=$(node -p "require('./package.json').version") + PUBLISHED_VERSION=$(npm view @librechat/data-schemas version 2>/dev/null || echo "0.0.0") + if [ "$PACKAGE_VERSION" = "$PUBLISHED_VERSION" ]; then + echo "No version change, skipping publish" + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "Version changed, proceeding with publish" + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Pack package + if: steps.check.outputs.skip != 'true' + working-directory: packages/data-schemas + run: npm pack + + - name: Publish + if: steps.check.outputs.skip != 'true' + working-directory: packages/data-schemas + run: npm publish *.tgz --access public \ No newline at end of file diff --git a/.github/workflows/generate-release-changelog-pr.yml b/.github/workflows/generate-release-changelog-pr.yml index c3bceae9de2..004431e577c 100644 --- a/.github/workflows/generate-release-changelog-pr.yml +++ b/.github/workflows/generate-release-changelog-pr.yml @@ -84,11 +84,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} sign-commits: true - commit-message: "chore: update CHANGELOG for release ${GITHUB_REF##*/}" + commit-message: "chore: update CHANGELOG for release ${{ github.ref_name }}" base: main - branch: "changelog/${GITHUB_REF##*/}" + branch: "changelog/${{ github.ref_name }}" reviewers: danny-avila - title: "chore: update CHANGELOG for release ${GITHUB_REF##*/}" + title: "chore: update CHANGELOG for release ${{ github.ref_name }}" body: | **Description**: - - This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${GITHUB_REF##*/} above previous releases. \ No newline at end of file + - This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..292bd76f404 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### โœจ New Features + +- ๐Ÿช„ feat: Agent Artifacts by **@danny-avila** in [#5804](https://github.com/danny-avila/LibreChat/pull/5804) + +### โš™๏ธ Other Changes + +- ๐Ÿ”„ chore: Enforce 18next Language Keys by **@rubentalstra** in [#5803](https://github.com/danny-avila/LibreChat/pull/5803) +- ๐Ÿ”ƒ refactor: Parent Message ID Handling on Error, Update Translations, Bump Agents by **@danny-avila** in [#5833](https://github.com/danny-avila/LibreChat/pull/5833) + +--- diff --git a/Dockerfile b/Dockerfile index 46cabe6dff0..d9113eb650a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.7.7-rc1 +# v0.7.7 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index 570fbecf317..40721137bb0 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.7.7-rc1 +# v0.7.7 # Base for all builds FROM node:20-alpine AS base-min @@ -11,6 +11,7 @@ RUN npm config set fetch-retry-maxtimeout 600000 && \ COPY package*.json ./ COPY packages/data-provider/package*.json ./packages/data-provider/ COPY packages/mcp/package*.json ./packages/mcp/ +COPY packages/data-schemas/package*.json ./packages/data-schemas/ COPY client/package*.json ./client/ COPY api/package*.json ./api/ @@ -32,6 +33,13 @@ COPY packages/mcp ./ COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist RUN npm run build +# Build data-schemas +FROM base AS data-schemas-build +WORKDIR /app/packages/data-schemas +COPY packages/data-schemas ./ +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +RUN npm run build + # Client build FROM base AS client-build WORKDIR /app/client @@ -49,8 +57,9 @@ COPY api ./api COPY config ./config COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist COPY --from=mcp-build /app/packages/mcp/dist ./packages/mcp/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=client-build /app/client/dist ./client/dist WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 -CMD ["node", "server/index.js"] +CMD ["node", "server/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index 2e662ac262f..3e02c2cc088 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ - [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control - ๐Ÿ’ฌ **Multimodal & File Interactions**: - - Upload and analyze images with Claude 3, GPT-4o, o1, Llama-Vision, and Gemini ๐Ÿ“ธ + - Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini ๐Ÿ“ธ - Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google ๐Ÿ—ƒ๏ธ - ๐ŸŒŽ **Multilingual UI**: @@ -197,6 +197,6 @@ We thank [Locize](https://locize.com) for their translation management tools tha

- Locize Logo + Locize Logo

diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 1f7eb90b26d..19f4a3930a2 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -7,8 +7,7 @@ const { getResponseSender, validateVisionModel, } = require('librechat-data-provider'); -const { SplitStreamHandler, GraphEvents } = require('@librechat/agents'); -const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { SplitStreamHandler: _Handler, GraphEvents } = require('@librechat/agents'); const { truncateText, formatMessage, @@ -24,6 +23,7 @@ const { } = require('~/server/services/Endpoints/anthropic/helpers'); const { getModelMaxTokens, getModelMaxOutputTokens, matchModelName } = require('~/utils'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); +const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const Tokenizer = require('~/server/services/Tokenizer'); const { logger, sendEvent } = require('~/config'); const { sleep } = require('~/server/utils'); @@ -32,6 +32,15 @@ const BaseClient = require('./BaseClient'); const HUMAN_PROMPT = '\n\nHuman:'; const AI_PROMPT = '\n\nAssistant:'; +class SplitStreamHandler extends _Handler { + getDeltaContent(chunk) { + return (chunk?.delta?.text ?? chunk?.completion) || ''; + } + getReasoningDelta(chunk) { + return chunk?.delta?.thinking || ''; + } +} + /** Helper function to introduce a delay before retrying */ function delayBeforeRetry(attempts, baseDelay = 1000) { return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts)); @@ -105,7 +114,9 @@ class AnthropicClient extends BaseClient { const modelMatch = matchModelName(this.modelOptions.model, EModelEndpoint.anthropic); this.isClaude3 = modelMatch.includes('claude-3'); - this.isLegacyOutput = !modelMatch.includes('claude-3-5-sonnet'); + this.isLegacyOutput = !( + /claude-3[-.]5-sonnet/.test(modelMatch) || /claude-3[-.]7/.test(modelMatch) + ); this.supportsCacheControl = this.options.promptCache && checkPromptCacheSupport(modelMatch); if ( @@ -733,8 +744,6 @@ class AnthropicClient extends BaseClient { stop_sequences, temperature, metadata, - top_p, - top_k, }; if (this.useMessages) { @@ -751,6 +760,14 @@ class AnthropicClient extends BaseClient { thinkingBudget: this.options.thinkingBudget, }); + if (!/claude-3[-.]7/.test(model)) { + requestOptions.top_p = top_p; + requestOptions.top_k = top_k; + } else if (requestOptions.thinking == null) { + requestOptions.topP = top_p; + requestOptions.topK = top_k; + } + if (this.systemMessage && this.supportsCacheControl === true) { requestOptions.system = [ { @@ -798,50 +815,16 @@ class AnthropicClient extends BaseClient { } }); - /** @param {string} chunk */ - const handleChunk = (chunk) => { - this.streamHandler.handle({ - choices: [ - { - delta: { - content: chunk, - }, - }, - ], - }); - }; - /** @param {string} chunk */ - const handleReasoningChunk = (chunk) => { - this.streamHandler.handle({ - choices: [ - { - delta: { - reasoning_content: chunk, - }, - }, - ], - }); - }; - for await (const completion of response) { - // Handle each completion as before const type = completion?.type ?? ''; if (tokenEventTypes.has(type)) { logger.debug(`[AnthropicClient] ${type}`, completion); this[type] = completion; } - if (completion?.delta?.thinking) { - handleReasoningChunk(completion.delta.thinking); - } else if (completion?.delta?.text) { - handleChunk(completion.delta.text); - } else if (completion.completion) { - handleChunk(completion.completion); - } - + this.streamHandler.handle(completion); await sleep(streamRate); } - // Successful processing, exit loop break; } catch (error) { attempts += 1; diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index ebf3ca12d9e..61b39a8f6de 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -5,10 +5,11 @@ const { isAgentsEndpoint, isParamEndpoint, EModelEndpoint, + excludedKeys, ErrorTypes, Constants, } = require('librechat-data-provider'); -const { getMessages, saveMessage, updateMessage, saveConvo } = require('~/models'); +const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models'); const { addSpaceIfNeeded, isEnabled } = require('~/server/utils'); const { truncateToolCallOutputs } = require('./prompts'); const checkBalance = require('~/models/checkBalance'); @@ -55,6 +56,10 @@ class BaseClient { * Flag to determine if the client re-submitted the latest assistant message. * @type {boolean | undefined} */ this.continued; + /** + * Flag to determine if the client has already fetched the conversation while saving new messages. + * @type {boolean | undefined} */ + this.fetchedConvo; /** @type {TMessage[]} */ this.currentMessages = []; /** @type {import('librechat-data-provider').VisionModes | undefined} */ @@ -863,16 +868,39 @@ class BaseClient { return { message: savedMessage }; } - const conversation = await saveConvo( - this.options.req, - { - conversationId: message.conversationId, - endpoint: this.options.endpoint, - endpointType: this.options.endpointType, - ...endpointOptions, - }, - { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo' }, - ); + const fieldsToKeep = { + conversationId: message.conversationId, + endpoint: this.options.endpoint, + endpointType: this.options.endpointType, + ...endpointOptions, + }; + + const existingConvo = + this.fetchedConvo === true + ? null + : await getConvo(this.options.req?.user?.id, message.conversationId); + + const unsetFields = {}; + if (existingConvo != null) { + this.fetchedConvo = true; + for (const key in existingConvo) { + if (!key) { + continue; + } + if (excludedKeys.has(key)) { + continue; + } + + if (endpointOptions?.[key] === undefined) { + unsetFields[key] = 1; + } + } + } + + const conversation = await saveConvo(this.options.req, fieldsToKeep, { + context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo', + unsetFields, + }); return { message: savedMessage, conversation }; } diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index e816843ea7d..58ee783d2a5 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -827,7 +827,8 @@ class GoogleClient extends BaseClient { let reply = ''; const { abortController } = options; - const model = this.modelOptions.modelName ?? this.modelOptions.model ?? ''; + const model = + this.options.titleModel ?? this.modelOptions.modelName ?? this.modelOptions.model ?? ''; const safetySettings = getSafetySettings(model); if (!EXCLUDED_GENAI_MODELS.test(model) && !this.project_id) { logger.debug('Identified titling model as GenAI version'); diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 7bd7879dcfb..9a89e34879a 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -109,15 +109,15 @@ class OpenAIClient extends BaseClient { const omniPattern = /\b(o1|o3)\b/i; this.isOmni = omniPattern.test(this.modelOptions.model); - const { OPENROUTER_API_KEY, OPENAI_FORCE_PROMPT } = process.env ?? {}; - if (OPENROUTER_API_KEY && !this.azure) { - this.apiKey = OPENROUTER_API_KEY; - this.useOpenRouter = true; - } - + const { OPENAI_FORCE_PROMPT } = process.env ?? {}; const { reverseProxyUrl: reverseProxy } = this.options; - if (!this.useOpenRouter && reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) { + if ( + !this.useOpenRouter && + ((reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) || + (this.options.endpoint && + this.options.endpoint.toLowerCase().includes(KnownEndpoints.openrouter))) + ) { this.useOpenRouter = true; } @@ -303,7 +303,9 @@ class OpenAIClient extends BaseClient { } getEncoding() { - return this.model?.includes('gpt-4o') ? 'o200k_base' : 'cl100k_base'; + return this.modelOptions?.model && /gpt-4[^-\s]/.test(this.modelOptions.model) + ? 'o200k_base' + : 'cl100k_base'; } /** @@ -610,7 +612,7 @@ class OpenAIClient extends BaseClient { } initializeLLM({ - model = 'gpt-4o-mini', + model = openAISettings.model.default, modelName, temperature = 0.2, max_tokens, @@ -711,7 +713,7 @@ class OpenAIClient extends BaseClient { const { OPENAI_TITLE_MODEL } = process.env ?? {}; - let model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-4o-mini'; + let model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? openAISettings.model.default; if (model === Constants.CURRENT_MODEL) { model = this.modelOptions.model; } @@ -904,7 +906,7 @@ ${convo} let prompt; // TODO: remove the gpt fallback and make it specific to endpoint - const { OPENAI_SUMMARY_MODEL = 'gpt-4o-mini' } = process.env ?? {}; + const { OPENAI_SUMMARY_MODEL = openAISettings.model.default } = process.env ?? {}; let model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL; if (model === Constants.CURRENT_MODEL) { model = this.modelOptions.model; @@ -1305,8 +1307,12 @@ ${convo} ) { delete modelOptions.stream; delete modelOptions.stop; - } else if (!this.isOmni && modelOptions.reasoning_effort != null) { + } else if ( + (!this.isOmni || /^o1-(mini|preview)/i.test(modelOptions.model)) && + modelOptions.reasoning_effort != null + ) { delete modelOptions.reasoning_effort; + delete modelOptions.temperature; } let reasoningKey = 'reasoning_content'; @@ -1314,6 +1320,12 @@ ${convo} modelOptions.include_reasoning = true; reasoningKey = 'reasoning'; } + if (this.useOpenRouter && modelOptions.reasoning_effort != null) { + modelOptions.reasoning = { + effort: modelOptions.reasoning_effort, + }; + delete modelOptions.reasoning_effort; + } this.streamHandler = new SplitStreamHandler({ reasoningKey, diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js index 957409d6abf..360fa00a341 100644 --- a/api/app/clients/prompts/formatAgentMessages.spec.js +++ b/api/app/clients/prompts/formatAgentMessages.spec.js @@ -325,4 +325,37 @@ describe('formatAgentMessages', () => { ); expect(result[0].content).not.toContain('Analyzing the problem...'); }); + + it('should exclude ERROR type content parts', () => { + const payload = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' }, + { + type: ContentTypes.ERROR, + [ContentTypes.ERROR]: + 'An error occurred while processing the request: Something went wrong', + }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' }, + ], + }, + ]; + + const result = formatAgentMessages(payload); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AIMessage); + expect(result[0].content).toEqual([ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Hello there' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' }, + ]); + + // Make sure no error content exists in the result + const hasErrorContent = result[0].content.some( + (item) => + item.type === ContentTypes.ERROR || JSON.stringify(item).includes('An error occurred'), + ); + expect(hasErrorContent).toBe(false); + }); }); diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js index 235e51e51f1..4e8d3bd5a59 100644 --- a/api/app/clients/prompts/formatMessages.js +++ b/api/app/clients/prompts/formatMessages.js @@ -211,6 +211,8 @@ const formatAgentMessages = (payload) => { } else if (part.type === ContentTypes.THINK) { hasReasoning = true; continue; + } else if (part.type === ContentTypes.ERROR) { + continue; } else { currentContent.push(part); } diff --git a/api/app/clients/specs/AnthropicClient.test.js b/api/app/clients/specs/AnthropicClient.test.js index eef6bb6748a..223f3038c0c 100644 --- a/api/app/clients/specs/AnthropicClient.test.js +++ b/api/app/clients/specs/AnthropicClient.test.js @@ -1,3 +1,4 @@ +const { SplitStreamHandler } = require('@librechat/agents'); const { anthropicSettings } = require('librechat-data-provider'); const AnthropicClient = require('~/app/clients/AnthropicClient'); @@ -405,4 +406,327 @@ describe('AnthropicClient', () => { expect(Number.isNaN(result)).toBe(false); }); }); + + describe('maxOutputTokens handling for different models', () => { + it('should not cap maxOutputTokens for Claude 3.5 Sonnet models', () => { + const client = new AnthropicClient('test-api-key'); + const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 10; + + client.setOptions({ + modelOptions: { + model: 'claude-3-5-sonnet', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue); + + // Test with decimal notation + client.setOptions({ + modelOptions: { + model: 'claude-3.5-sonnet', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue); + }); + + it('should not cap maxOutputTokens for Claude 3.7 models', () => { + const client = new AnthropicClient('test-api-key'); + const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2; + + client.setOptions({ + modelOptions: { + model: 'claude-3-7-sonnet', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue); + + // Test with decimal notation + client.setOptions({ + modelOptions: { + model: 'claude-3.7-sonnet', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe(highTokenValue); + }); + + it('should cap maxOutputTokens for Claude 3.5 Haiku models', () => { + const client = new AnthropicClient('test-api-key'); + const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2; + + client.setOptions({ + modelOptions: { + model: 'claude-3-5-haiku', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe( + anthropicSettings.legacy.maxOutputTokens.default, + ); + + // Test with decimal notation + client.setOptions({ + modelOptions: { + model: 'claude-3.5-haiku', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe( + anthropicSettings.legacy.maxOutputTokens.default, + ); + }); + + it('should cap maxOutputTokens for Claude 3 Haiku and Opus models', () => { + const client = new AnthropicClient('test-api-key'); + const highTokenValue = anthropicSettings.legacy.maxOutputTokens.default * 2; + + // Test haiku + client.setOptions({ + modelOptions: { + model: 'claude-3-haiku', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe( + anthropicSettings.legacy.maxOutputTokens.default, + ); + + // Test opus + client.setOptions({ + modelOptions: { + model: 'claude-3-opus', + maxOutputTokens: highTokenValue, + }, + }); + + expect(client.modelOptions.maxOutputTokens).toBe( + anthropicSettings.legacy.maxOutputTokens.default, + ); + }); + }); + + describe('topK/topP parameters for different models', () => { + beforeEach(() => { + // Mock the SplitStreamHandler + jest.spyOn(SplitStreamHandler.prototype, 'handle').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should include top_k and top_p parameters for non-claude-3.7 models', async () => { + const client = new AnthropicClient('test-api-key'); + + // Create a mock async generator function + async function* mockAsyncGenerator() { + yield { type: 'message_start', message: { usage: {} } }; + yield { delta: { text: 'Test response' } }; + yield { type: 'message_delta', usage: {} }; + } + + // Mock createResponse to return the async generator + jest.spyOn(client, 'createResponse').mockImplementation(() => { + return mockAsyncGenerator(); + }); + + client.setOptions({ + modelOptions: { + model: 'claude-3-opus', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + }); + + // Mock getClient to capture the request options + let capturedOptions = null; + jest.spyOn(client, 'getClient').mockImplementation((options) => { + capturedOptions = options; + return {}; + }); + + const payload = [{ role: 'user', content: 'Test message' }]; + await client.sendCompletion(payload, {}); + + // Check the options passed to getClient + expect(capturedOptions).toHaveProperty('top_k', 10); + expect(capturedOptions).toHaveProperty('top_p', 0.9); + }); + + it('should include top_k and top_p parameters for claude-3-5-sonnet models', async () => { + const client = new AnthropicClient('test-api-key'); + + // Create a mock async generator function + async function* mockAsyncGenerator() { + yield { type: 'message_start', message: { usage: {} } }; + yield { delta: { text: 'Test response' } }; + yield { type: 'message_delta', usage: {} }; + } + + // Mock createResponse to return the async generator + jest.spyOn(client, 'createResponse').mockImplementation(() => { + return mockAsyncGenerator(); + }); + + client.setOptions({ + modelOptions: { + model: 'claude-3-5-sonnet', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + }); + + // Mock getClient to capture the request options + let capturedOptions = null; + jest.spyOn(client, 'getClient').mockImplementation((options) => { + capturedOptions = options; + return {}; + }); + + const payload = [{ role: 'user', content: 'Test message' }]; + await client.sendCompletion(payload, {}); + + // Check the options passed to getClient + expect(capturedOptions).toHaveProperty('top_k', 10); + expect(capturedOptions).toHaveProperty('top_p', 0.9); + }); + + it('should not include top_k and top_p parameters for claude-3-7-sonnet models', async () => { + const client = new AnthropicClient('test-api-key'); + + // Create a mock async generator function + async function* mockAsyncGenerator() { + yield { type: 'message_start', message: { usage: {} } }; + yield { delta: { text: 'Test response' } }; + yield { type: 'message_delta', usage: {} }; + } + + // Mock createResponse to return the async generator + jest.spyOn(client, 'createResponse').mockImplementation(() => { + return mockAsyncGenerator(); + }); + + client.setOptions({ + modelOptions: { + model: 'claude-3-7-sonnet', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + }); + + // Mock getClient to capture the request options + let capturedOptions = null; + jest.spyOn(client, 'getClient').mockImplementation((options) => { + capturedOptions = options; + return {}; + }); + + const payload = [{ role: 'user', content: 'Test message' }]; + await client.sendCompletion(payload, {}); + + // Check the options passed to getClient + expect(capturedOptions).not.toHaveProperty('top_k'); + expect(capturedOptions).not.toHaveProperty('top_p'); + }); + + it('should not include top_k and top_p parameters for models with decimal notation (claude-3.7)', async () => { + const client = new AnthropicClient('test-api-key'); + + // Create a mock async generator function + async function* mockAsyncGenerator() { + yield { type: 'message_start', message: { usage: {} } }; + yield { delta: { text: 'Test response' } }; + yield { type: 'message_delta', usage: {} }; + } + + // Mock createResponse to return the async generator + jest.spyOn(client, 'createResponse').mockImplementation(() => { + return mockAsyncGenerator(); + }); + + client.setOptions({ + modelOptions: { + model: 'claude-3.7-sonnet', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + }); + + // Mock getClient to capture the request options + let capturedOptions = null; + jest.spyOn(client, 'getClient').mockImplementation((options) => { + capturedOptions = options; + return {}; + }); + + const payload = [{ role: 'user', content: 'Test message' }]; + await client.sendCompletion(payload, {}); + + // Check the options passed to getClient + expect(capturedOptions).not.toHaveProperty('top_k'); + expect(capturedOptions).not.toHaveProperty('top_p'); + }); + }); + + it('should include top_k and top_p parameters for Claude-3.7 models when thinking is explicitly disabled', async () => { + const client = new AnthropicClient('test-api-key', { + modelOptions: { + model: 'claude-3-7-sonnet', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + thinking: false, + }); + + async function* mockAsyncGenerator() { + yield { type: 'message_start', message: { usage: {} } }; + yield { delta: { text: 'Test response' } }; + yield { type: 'message_delta', usage: {} }; + } + + jest.spyOn(client, 'createResponse').mockImplementation(() => { + return mockAsyncGenerator(); + }); + + let capturedOptions = null; + jest.spyOn(client, 'getClient').mockImplementation((options) => { + capturedOptions = options; + return {}; + }); + + const payload = [{ role: 'user', content: 'Test message' }]; + await client.sendCompletion(payload, {}); + + expect(capturedOptions).toHaveProperty('topK', 10); + expect(capturedOptions).toHaveProperty('topP', 0.9); + + client.setOptions({ + modelOptions: { + model: 'claude-3.7-sonnet', + temperature: 0.7, + topK: 10, + topP: 0.9, + }, + thinking: false, + }); + + await client.sendCompletion(payload, {}); + + expect(capturedOptions).toHaveProperty('topK', 10); + expect(capturedOptions).toHaveProperty('topP', 0.9); + }); }); diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index e899449fb90..0dae5b14d37 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -30,6 +30,8 @@ jest.mock('~/models', () => ({ updateFileUsage: jest.fn(), })); +const { getConvo, saveConvo } = require('~/models'); + jest.mock('@langchain/openai', () => { return { ChatOpenAI: jest.fn().mockImplementation(() => { @@ -540,10 +542,11 @@ describe('BaseClient', () => { test('saveMessageToDatabase is called with the correct arguments', async () => { const saveOptions = TestClient.getSaveOptions(); - const user = {}; // Mock user + const user = {}; const opts = { user }; + const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase'); await TestClient.sendMessage('Hello, world!', opts); - expect(TestClient.saveMessageToDatabase).toHaveBeenCalledWith( + expect(saveSpy).toHaveBeenCalledWith( expect.objectContaining({ sender: expect.any(String), text: expect.any(String), @@ -557,6 +560,157 @@ describe('BaseClient', () => { ); }); + test('should handle existing conversation when getConvo retrieves one', async () => { + const existingConvo = { + conversationId: 'existing-convo-id', + endpoint: 'openai', + endpointType: 'openai', + model: 'gpt-3.5-turbo', + messages: [ + { role: 'user', content: 'Existing message 1' }, + { role: 'assistant', content: 'Existing response 1' }, + ], + temperature: 1, + }; + + const { temperature: _temp, ...newConvo } = existingConvo; + + const user = { + id: 'user-id', + }; + + getConvo.mockResolvedValue(existingConvo); + saveConvo.mockResolvedValue(newConvo); + + TestClient = initializeFakeClient( + apiKey, + { + ...options, + req: { + user, + }, + }, + [], + ); + + const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase'); + + const newMessage = 'New message in existing conversation'; + const response = await TestClient.sendMessage(newMessage, { + user, + conversationId: existingConvo.conversationId, + }); + + expect(getConvo).toHaveBeenCalledWith(user.id, existingConvo.conversationId); + expect(TestClient.conversationId).toBe(existingConvo.conversationId); + expect(response.conversationId).toBe(existingConvo.conversationId); + expect(TestClient.fetchedConvo).toBe(true); + + expect(saveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: existingConvo.conversationId, + text: newMessage, + }), + expect.any(Object), + expect.any(Object), + ); + + expect(saveConvo).toHaveBeenCalledTimes(2); + expect(saveConvo).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + conversationId: existingConvo.conversationId, + }), + expect.objectContaining({ + context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo', + unsetFields: { + temperature: 1, + }, + }), + ); + + await TestClient.sendMessage('Another message', { + conversationId: existingConvo.conversationId, + }); + expect(getConvo).toHaveBeenCalledTimes(1); + }); + + test('should correctly handle existing conversation and unset fields appropriately', async () => { + const existingConvo = { + conversationId: 'existing-convo-id', + endpoint: 'openai', + endpointType: 'openai', + model: 'gpt-3.5-turbo', + messages: [ + { role: 'user', content: 'Existing message 1' }, + { role: 'assistant', content: 'Existing response 1' }, + ], + title: 'Existing Conversation', + someExistingField: 'existingValue', + anotherExistingField: 'anotherValue', + temperature: 0.7, + modelLabel: 'GPT-3.5', + }; + + getConvo.mockResolvedValue(existingConvo); + saveConvo.mockResolvedValue(existingConvo); + + TestClient = initializeFakeClient( + apiKey, + { + ...options, + modelOptions: { + model: 'gpt-4', + temperature: 0.5, + }, + }, + [], + ); + + const newMessage = 'New message in existing conversation'; + await TestClient.sendMessage(newMessage, { + conversationId: existingConvo.conversationId, + }); + + expect(saveConvo).toHaveBeenCalledTimes(2); + + const saveConvoCall = saveConvo.mock.calls[0]; + const [, savedFields, saveOptions] = saveConvoCall; + + // Instead of checking all excludedKeys, we'll just check specific fields + // that we know should be excluded + expect(savedFields).not.toHaveProperty('messages'); + expect(savedFields).not.toHaveProperty('title'); + + // Only check that someExistingField is in unsetFields + expect(saveOptions.unsetFields).toHaveProperty('someExistingField', 1); + + // Mock saveConvo to return the expected fields + saveConvo.mockImplementation((req, fields) => { + return Promise.resolve({ + ...fields, + endpoint: 'openai', + endpointType: 'openai', + model: 'gpt-4', + temperature: 0.5, + }); + }); + + // Only check the conversationId since that's the only field we can be sure about + expect(savedFields).toHaveProperty('conversationId', 'existing-convo-id'); + + expect(TestClient.fetchedConvo).toBe(true); + + await TestClient.sendMessage('Another message', { + conversationId: existingConvo.conversationId, + }); + + expect(getConvo).toHaveBeenCalledTimes(1); + + const secondSaveConvoCall = saveConvo.mock.calls[1]; + expect(secondSaveConvoCall[2]).toHaveProperty('unsetFields', {}); + }); + test('sendCompletion is called with the correct arguments', async () => { const payload = {}; // Mock payload TestClient.buildMessages.mockReturnValue({ prompt: payload, tokenCountMap: null }); diff --git a/api/app/clients/specs/FakeClient.js b/api/app/clients/specs/FakeClient.js index 7f4b75e1db9..a466bb97f9d 100644 --- a/api/app/clients/specs/FakeClient.js +++ b/api/app/clients/specs/FakeClient.js @@ -56,7 +56,6 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => { let TestClient = new FakeClient(apiKey); TestClient.options = options; TestClient.abortController = { abort: jest.fn() }; - TestClient.saveMessageToDatabase = jest.fn(); TestClient.loadHistory = jest .fn() .mockImplementation((conversationId, parentMessageId = null) => { @@ -86,7 +85,6 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => { return 'Mock response text'; }); - // eslint-disable-next-line no-unused-vars TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => { return { choices: [ diff --git a/api/app/clients/specs/OpenAIClient.test.js b/api/app/clients/specs/OpenAIClient.test.js index 2aaec518ebe..0e811cf38a4 100644 --- a/api/app/clients/specs/OpenAIClient.test.js +++ b/api/app/clients/specs/OpenAIClient.test.js @@ -202,14 +202,6 @@ describe('OpenAIClient', () => { expect(client.modelOptions.temperature).toBe(0.7); }); - it('should set apiKey and useOpenRouter if OPENROUTER_API_KEY is present', () => { - process.env.OPENROUTER_API_KEY = 'openrouter-key'; - client.setOptions({}); - expect(client.apiKey).toBe('openrouter-key'); - expect(client.useOpenRouter).toBe(true); - delete process.env.OPENROUTER_API_KEY; // Cleanup - }); - it('should set FORCE_PROMPT based on OPENAI_FORCE_PROMPT or reverseProxyUrl', () => { process.env.OPENAI_FORCE_PROMPT = 'true'; client.setOptions({}); @@ -534,7 +526,6 @@ describe('OpenAIClient', () => { afterEach(() => { delete process.env.AZURE_OPENAI_DEFAULT_MODEL; delete process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME; - delete process.env.OPENROUTER_API_KEY; }); it('should call getCompletion and fetchEventSource when using a text/instruct model', async () => { diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index b8df50c77d9..df436fb0890 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -2,9 +2,10 @@ const availableTools = require('./manifest.json'); // Structured Tools const DALLE3 = require('./structured/DALLE3'); +const FluxAPI = require('./structured/FluxAPI'); const OpenWeather = require('./structured/OpenWeather'); -const createYouTubeTools = require('./structured/YouTube'); const StructuredWolfram = require('./structured/Wolfram'); +const createYouTubeTools = require('./structured/YouTube'); const StructuredACS = require('./structured/AzureAISearch'); const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); @@ -30,6 +31,7 @@ module.exports = { manifestToolMap, // Structured Tools DALLE3, + FluxAPI, OpenWeather, StructuredSD, StructuredACS, diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 7cb92b8d877..43be7a4e6c5 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -164,5 +164,19 @@ "description": "Sign up at OpenWeather, then get your key at API keys." } ] + }, + { + "name": "Flux", + "pluginKey": "flux", + "description": "Generate images using text with the Flux API.", + "icon": "https://blackforestlabs.ai/wp-content/uploads/2024/07/bfl_logo_retraced_blk.png", + "isAuthRequired": "true", + "authConfig": [ + { + "authField": "FLUX_API_KEY", + "label": "Your Flux API Key", + "description": "Provide your Flux API key from your user profile." + } + ] } ] diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index b604ad4ea46..81200e3a61e 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -1,14 +1,17 @@ const { z } = require('zod'); const path = require('path'); const OpenAI = require('openai'); +const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const { FileContext } = require('librechat-data-provider'); +const { FileContext, ContentTypes } = require('librechat-data-provider'); const { getImageBasename } = require('~/server/services/Files/images'); const extractBaseURL = require('~/utils/extractBaseURL'); const { logger } = require('~/config'); +const displayMessage = + 'DALL-E displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; class DALLE3 extends Tool { constructor(fields = {}) { super(); @@ -114,10 +117,7 @@ class DALLE3 extends Tool { if (this.isAgent === true && typeof value === 'string') { return [value, {}]; } else if (this.isAgent === true && typeof value === 'object') { - return [ - 'DALL-E displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.', - value, - ]; + return [displayMessage, value]; } return value; @@ -160,6 +160,32 @@ Error Message: ${error.message}`); ); } + if (this.isAgent) { + let fetchOptions = {}; + if (process.env.PROXY) { + fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); + } + const imageResponse = await fetch(theImageUrl, fetchOptions); + const arrayBuffer = await imageResponse.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString('base64'); + const content = [ + { + type: ContentTypes.IMAGE_URL, + image_url: { + url: `data:image/jpeg;base64,${base64}`, + }, + }, + ]; + + const response = [ + { + type: ContentTypes.TEXT, + text: displayMessage, + }, + ]; + return [response, { content }]; + } + const imageBasename = getImageBasename(theImageUrl); const imageExt = path.extname(imageBasename); diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js new file mode 100644 index 00000000000..80f97722004 --- /dev/null +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -0,0 +1,554 @@ +const { z } = require('zod'); +const axios = require('axios'); +const fetch = require('node-fetch'); +const { v4: uuidv4 } = require('uuid'); +const { Tool } = require('@langchain/core/tools'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { FileContext, ContentTypes } = require('librechat-data-provider'); +const { logger } = require('~/config'); + +const displayMessage = + 'Flux displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + +/** + * FluxAPI - A tool for generating high-quality images from text prompts using the Flux API. + * Each call generates one image. If multiple images are needed, make multiple consecutive calls with the same or varied prompts. + */ +class FluxAPI extends Tool { + // Pricing constants in USD per image + static PRICING = { + FLUX_PRO_1_1_ULTRA: -0.06, // /v1/flux-pro-1.1-ultra + FLUX_PRO_1_1: -0.04, // /v1/flux-pro-1.1 + FLUX_PRO: -0.05, // /v1/flux-pro + FLUX_DEV: -0.025, // /v1/flux-dev + FLUX_PRO_FINETUNED: -0.06, // /v1/flux-pro-finetuned + FLUX_PRO_1_1_ULTRA_FINETUNED: -0.07, // /v1/flux-pro-1.1-ultra-finetuned + }; + + constructor(fields = {}) { + super(); + + /** @type {boolean} Used to initialize the Tool without necessary variables. */ + this.override = fields.override ?? false; + + this.userId = fields.userId; + this.fileStrategy = fields.fileStrategy; + + /** @type {boolean} **/ + this.isAgent = fields.isAgent; + this.returnMetadata = fields.returnMetadata ?? false; + + if (fields.processFileURL) { + /** @type {processFileURL} Necessary for output to contain all image metadata. */ + this.processFileURL = fields.processFileURL.bind(this); + } + + this.apiKey = fields.FLUX_API_KEY || this.getApiKey(); + + this.name = 'flux'; + this.description = + 'Use Flux to generate images from text descriptions. This tool can generate images and list available finetunes. Each generate call creates one image. For multiple images, make multiple consecutive calls.'; + + this.description_for_model = `// Transform any image description into a detailed, high-quality prompt. Never submit a prompt under 3 sentences. Follow these core rules: + // 1. ALWAYS enhance basic prompts into 5-10 detailed sentences (e.g., "a cat" becomes: "A close-up photo of a sleek Siamese cat with piercing blue eyes. The cat sits elegantly on a vintage leather armchair, its tail curled gracefully around its paws. Warm afternoon sunlight streams through a nearby window, casting gentle shadows across its face and highlighting the subtle variations in its cream and chocolate-point fur. The background is softly blurred, creating a shallow depth of field that draws attention to the cat's expressive features. The overall composition has a peaceful, contemplative mood with a professional photography style.") + // 2. Each prompt MUST be 3-6 descriptive sentences minimum, focusing on visual elements: lighting, composition, mood, and style + // Use action: 'list_finetunes' to see available custom models. When using finetunes, use endpoint: '/v1/flux-pro-finetuned' (default) or '/v1/flux-pro-1.1-ultra-finetuned' for higher quality and aspect ratio.`; + + // Add base URL from environment variable with fallback + this.baseUrl = process.env.FLUX_API_BASE_URL || 'https://api.us1.bfl.ai'; + + // Define the schema for structured input + this.schema = z.object({ + action: z + .enum(['generate', 'list_finetunes', 'generate_finetuned']) + .default('generate') + .describe( + 'Action to perform: "generate" for image generation, "generate_finetuned" for finetuned model generation, "list_finetunes" to get available custom models', + ), + prompt: z + .string() + .optional() + .describe( + 'Text prompt for image generation. Required when action is "generate". Not used for list_finetunes.', + ), + width: z + .number() + .optional() + .describe( + 'Width of the generated image in pixels. Must be a multiple of 32. Default is 1024.', + ), + height: z + .number() + .optional() + .describe( + 'Height of the generated image in pixels. Must be a multiple of 32. Default is 768.', + ), + prompt_upsampling: z + .boolean() + .optional() + .default(false) + .describe('Whether to perform upsampling on the prompt.'), + steps: z + .number() + .int() + .optional() + .describe('Number of steps to run the model for, a number from 1 to 50. Default is 40.'), + seed: z.number().optional().describe('Optional seed for reproducibility.'), + safety_tolerance: z + .number() + .optional() + .default(6) + .describe( + 'Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.', + ), + endpoint: z + .enum([ + '/v1/flux-pro-1.1', + '/v1/flux-pro', + '/v1/flux-dev', + '/v1/flux-pro-1.1-ultra', + '/v1/flux-pro-finetuned', + '/v1/flux-pro-1.1-ultra-finetuned', + ]) + .optional() + .default('/v1/flux-pro-1.1') + .describe('Endpoint to use for image generation.'), + raw: z + .boolean() + .optional() + .default(false) + .describe( + 'Generate less processed, more natural-looking images. Only works for /v1/flux-pro-1.1-ultra.', + ), + finetune_id: z.string().optional().describe('ID of the finetuned model to use'), + finetune_strength: z + .number() + .optional() + .default(1.1) + .describe('Strength of the finetuning effect (typically between 0.1 and 1.2)'), + guidance: z.number().optional().default(2.5).describe('Guidance scale for finetuned models'), + aspect_ratio: z + .string() + .optional() + .default('16:9') + .describe('Aspect ratio for ultra models (e.g., "16:9")'), + }); + } + + getAxiosConfig() { + const config = {}; + if (process.env.PROXY) { + config.httpsAgent = new HttpsProxyAgent(process.env.PROXY); + } + return config; + } + + /** @param {Object|string} value */ + getDetails(value) { + if (typeof value === 'string') { + return value; + } + return JSON.stringify(value, null, 2); + } + + getApiKey() { + const apiKey = process.env.FLUX_API_KEY || ''; + if (!apiKey && !this.override) { + throw new Error('Missing FLUX_API_KEY environment variable.'); + } + return apiKey; + } + + wrapInMarkdown(imageUrl) { + const serverDomain = process.env.DOMAIN_SERVER || 'http://localhost:3080'; + return `![generated image](${serverDomain}${imageUrl})`; + } + + returnValue(value) { + if (this.isAgent === true && typeof value === 'string') { + return [value, {}]; + } else if (this.isAgent === true && typeof value === 'object') { + if (Array.isArray(value)) { + return value; + } + return [displayMessage, value]; + } + return value; + } + + async _call(data) { + const { action = 'generate', ...imageData } = data; + + // Use provided API key for this request if available, otherwise use default + const requestApiKey = this.apiKey || this.getApiKey(); + + // Handle list_finetunes action + if (action === 'list_finetunes') { + return this.getMyFinetunes(requestApiKey); + } + + // Handle finetuned generation + if (action === 'generate_finetuned') { + return this.generateFinetunedImage(imageData, requestApiKey); + } + + // For generate action, ensure prompt is provided + if (!imageData.prompt) { + throw new Error('Missing required field: prompt'); + } + + let payload = { + prompt: imageData.prompt, + prompt_upsampling: imageData.prompt_upsampling || false, + safety_tolerance: imageData.safety_tolerance || 6, + output_format: imageData.output_format || 'png', + }; + + // Add optional parameters if provided + if (imageData.width) { + payload.width = imageData.width; + } + if (imageData.height) { + payload.height = imageData.height; + } + if (imageData.steps) { + payload.steps = imageData.steps; + } + if (imageData.seed !== undefined) { + payload.seed = imageData.seed; + } + if (imageData.raw) { + payload.raw = imageData.raw; + } + + const generateUrl = `${this.baseUrl}${imageData.endpoint || '/v1/flux-pro'}`; + const resultUrl = `${this.baseUrl}/v1/get_result`; + + logger.debug('[FluxAPI] Generating image with payload:', payload); + logger.debug('[FluxAPI] Using endpoint:', generateUrl); + + let taskResponse; + try { + taskResponse = await axios.post(generateUrl, payload, { + headers: { + 'x-key': requestApiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + ...this.getAxiosConfig(), + }); + } catch (error) { + const details = this.getDetails(error?.response?.data || error.message); + logger.error('[FluxAPI] Error while submitting task:', details); + + return this.returnValue( + `Something went wrong when trying to generate the image. The Flux API may be unavailable: + Error Message: ${details}`, + ); + } + + const taskId = taskResponse.data.id; + + // Polling for the result + let status = 'Pending'; + let resultData = null; + while (status !== 'Ready' && status !== 'Error') { + try { + // Wait 2 seconds between polls + await new Promise((resolve) => setTimeout(resolve, 2000)); + const resultResponse = await axios.get(resultUrl, { + headers: { + 'x-key': requestApiKey, + Accept: 'application/json', + }, + params: { id: taskId }, + ...this.getAxiosConfig(), + }); + status = resultResponse.data.status; + + if (status === 'Ready') { + resultData = resultResponse.data.result; + break; + } else if (status === 'Error') { + logger.error('[FluxAPI] Error in task:', resultResponse.data); + return this.returnValue('An error occurred during image generation.'); + } + } catch (error) { + const details = this.getDetails(error?.response?.data || error.message); + logger.error('[FluxAPI] Error while getting result:', details); + return this.returnValue('An error occurred while retrieving the image.'); + } + } + + // If no result data + if (!resultData || !resultData.sample) { + logger.error('[FluxAPI] No image data received from API. Response:', resultData); + return this.returnValue('No image data received from Flux API.'); + } + + // Try saving the image locally + const imageUrl = resultData.sample; + const imageName = `img-${uuidv4()}.png`; + + if (this.isAgent) { + try { + // Fetch the image and convert to base64 + const fetchOptions = {}; + if (process.env.PROXY) { + fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); + } + const imageResponse = await fetch(imageUrl, fetchOptions); + const arrayBuffer = await imageResponse.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString('base64'); + const content = [ + { + type: ContentTypes.IMAGE_URL, + image_url: { + url: `data:image/png;base64,${base64}`, + }, + }, + ]; + + const response = [ + { + type: ContentTypes.TEXT, + text: displayMessage, + }, + ]; + return [response, { content }]; + } catch (error) { + logger.error('Error processing image for agent:', error); + return this.returnValue(`Failed to process the image. ${error.message}`); + } + } + + try { + logger.debug('[FluxAPI] Saving image:', imageUrl); + const result = await this.processFileURL({ + fileStrategy: this.fileStrategy, + userId: this.userId, + URL: imageUrl, + fileName: imageName, + basePath: 'images', + context: FileContext.image_generation, + }); + + logger.debug('[FluxAPI] Image saved to path:', result.filepath); + + // Calculate cost based on endpoint + /** + * TODO: Cost handling + const endpoint = imageData.endpoint || '/v1/flux-pro'; + const endpointKey = Object.entries(FluxAPI.PRICING).find(([key, _]) => + endpoint.includes(key.toLowerCase().replace(/_/g, '-')), + )?.[0]; + const cost = FluxAPI.PRICING[endpointKey] || 0; + */ + this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath); + return this.returnValue(this.result); + } catch (error) { + const details = this.getDetails(error?.message ?? 'No additional error details.'); + logger.error('Error while saving the image:', details); + return this.returnValue(`Failed to save the image locally. ${details}`); + } + } + + async getMyFinetunes(apiKey = null) { + const finetunesUrl = `${this.baseUrl}/v1/my_finetunes`; + const detailsUrl = `${this.baseUrl}/v1/finetune_details`; + + try { + const headers = { + 'x-key': apiKey || this.getApiKey(), + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + + // Get list of finetunes + const response = await axios.get(finetunesUrl, { + headers, + ...this.getAxiosConfig(), + }); + const finetunes = response.data.finetunes; + + // Fetch details for each finetune + const finetuneDetails = await Promise.all( + finetunes.map(async (finetuneId) => { + try { + const detailResponse = await axios.get(`${detailsUrl}?finetune_id=${finetuneId}`, { + headers, + ...this.getAxiosConfig(), + }); + return { + id: finetuneId, + ...detailResponse.data, + }; + } catch (error) { + logger.error(`[FluxAPI] Error fetching details for finetune ${finetuneId}:`, error); + return { + id: finetuneId, + error: 'Failed to fetch details', + }; + } + }), + ); + + if (this.isAgent) { + const formattedDetails = JSON.stringify(finetuneDetails, null, 2); + return [`Here are the available finetunes:\n${formattedDetails}`, null]; + } + return JSON.stringify(finetuneDetails); + } catch (error) { + const details = this.getDetails(error?.response?.data || error.message); + logger.error('[FluxAPI] Error while getting finetunes:', details); + const errorMsg = `Failed to get finetunes: ${details}`; + return this.isAgent ? this.returnValue([errorMsg, {}]) : new Error(errorMsg); + } + } + + async generateFinetunedImage(imageData, requestApiKey) { + if (!imageData.prompt) { + throw new Error('Missing required field: prompt'); + } + + if (!imageData.finetune_id) { + throw new Error( + 'Missing required field: finetune_id for finetuned generation. Please supply a finetune_id!', + ); + } + + // Validate endpoint is appropriate for finetuned generation + const validFinetunedEndpoints = ['/v1/flux-pro-finetuned', '/v1/flux-pro-1.1-ultra-finetuned']; + const endpoint = imageData.endpoint || '/v1/flux-pro-finetuned'; + + if (!validFinetunedEndpoints.includes(endpoint)) { + throw new Error( + `Invalid endpoint for finetuned generation. Must be one of: ${validFinetunedEndpoints.join(', ')}`, + ); + } + + let payload = { + prompt: imageData.prompt, + prompt_upsampling: imageData.prompt_upsampling || false, + safety_tolerance: imageData.safety_tolerance || 6, + output_format: imageData.output_format || 'png', + finetune_id: imageData.finetune_id, + finetune_strength: imageData.finetune_strength || 1.0, + guidance: imageData.guidance || 2.5, + }; + + // Add optional parameters if provided + if (imageData.width) { + payload.width = imageData.width; + } + if (imageData.height) { + payload.height = imageData.height; + } + if (imageData.steps) { + payload.steps = imageData.steps; + } + if (imageData.seed !== undefined) { + payload.seed = imageData.seed; + } + if (imageData.raw) { + payload.raw = imageData.raw; + } + + const generateUrl = `${this.baseUrl}${endpoint}`; + const resultUrl = `${this.baseUrl}/v1/get_result`; + + logger.debug('[FluxAPI] Generating finetuned image with payload:', payload); + logger.debug('[FluxAPI] Using endpoint:', generateUrl); + + let taskResponse; + try { + taskResponse = await axios.post(generateUrl, payload, { + headers: { + 'x-key': requestApiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + ...this.getAxiosConfig(), + }); + } catch (error) { + const details = this.getDetails(error?.response?.data || error.message); + logger.error('[FluxAPI] Error while submitting finetuned task:', details); + return this.returnValue( + `Something went wrong when trying to generate the finetuned image. The Flux API may be unavailable: + Error Message: ${details}`, + ); + } + + const taskId = taskResponse.data.id; + + // Polling for the result + let status = 'Pending'; + let resultData = null; + while (status !== 'Ready' && status !== 'Error') { + try { + // Wait 2 seconds between polls + await new Promise((resolve) => setTimeout(resolve, 2000)); + const resultResponse = await axios.get(resultUrl, { + headers: { + 'x-key': requestApiKey, + Accept: 'application/json', + }, + params: { id: taskId }, + ...this.getAxiosConfig(), + }); + status = resultResponse.data.status; + + if (status === 'Ready') { + resultData = resultResponse.data.result; + break; + } else if (status === 'Error') { + logger.error('[FluxAPI] Error in finetuned task:', resultResponse.data); + return this.returnValue('An error occurred during finetuned image generation.'); + } + } catch (error) { + const details = this.getDetails(error?.response?.data || error.message); + logger.error('[FluxAPI] Error while getting finetuned result:', details); + return this.returnValue('An error occurred while retrieving the finetuned image.'); + } + } + + // If no result data + if (!resultData || !resultData.sample) { + logger.error('[FluxAPI] No image data received from API. Response:', resultData); + return this.returnValue('No image data received from Flux API.'); + } + + // Try saving the image locally + const imageUrl = resultData.sample; + const imageName = `img-${uuidv4()}.png`; + + try { + logger.debug('[FluxAPI] Saving finetuned image:', imageUrl); + const result = await this.processFileURL({ + fileStrategy: this.fileStrategy, + userId: this.userId, + URL: imageUrl, + fileName: imageName, + basePath: 'images', + context: FileContext.image_generation, + }); + + logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath); + + // Calculate cost based on endpoint + const endpointKey = endpoint.includes('ultra') + ? 'FLUX_PRO_1_1_ULTRA_FINETUNED' + : 'FLUX_PRO_FINETUNED'; + const cost = FluxAPI.PRICING[endpointKey] || 0; + // Return the result based on returnMetadata flag + this.result = this.returnMetadata ? result : this.wrapInMarkdown(result.filepath); + return this.returnValue(this.result); + } catch (error) { + const details = this.getDetails(error?.message ?? 'No additional error details.'); + logger.error('Error while saving the finetuned image:', details); + return this.returnValue(`Failed to save the finetuned image locally. ${details}`); + } + } +} + +module.exports = FluxAPI; diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index 6309da35d87..25a9e0abd30 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -6,10 +6,13 @@ const axios = require('axios'); const sharp = require('sharp'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); -const { FileContext } = require('librechat-data-provider'); +const { FileContext, ContentTypes } = require('librechat-data-provider'); const paths = require('~/config/paths'); const { logger } = require('~/config'); +const displayMessage = + 'Stable Diffusion displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + class StableDiffusionAPI extends Tool { constructor(fields) { super(); @@ -21,6 +24,8 @@ class StableDiffusionAPI extends Tool { this.override = fields.override ?? false; /** @type {boolean} Necessary for output to contain all image metadata. */ this.returnMetadata = fields.returnMetadata ?? false; + /** @type {boolean} */ + this.isAgent = fields.isAgent; if (fields.uploadImageBuffer) { /** @type {uploadImageBuffer} Necessary for output to contain all image metadata. */ this.uploadImageBuffer = fields.uploadImageBuffer.bind(this); @@ -66,6 +71,16 @@ class StableDiffusionAPI extends Tool { return `![generated image](/${imageUrl})`; } + returnValue(value) { + if (this.isAgent === true && typeof value === 'string') { + return [value, {}]; + } else if (this.isAgent === true && typeof value === 'object') { + return [displayMessage, value]; + } + + return value; + } + getServerURL() { const url = process.env.SD_WEBUI_URL || ''; if (!url && !this.override) { @@ -113,6 +128,25 @@ class StableDiffusionAPI extends Tool { } try { + if (this.isAgent) { + const content = [ + { + type: ContentTypes.IMAGE_URL, + image_url: { + url: `data:image/png;base64,${image}`, + }, + }, + ]; + + const response = [ + { + type: ContentTypes.TEXT, + text: displayMessage, + }, + ]; + return [response, { content }]; + } + const buffer = Buffer.from(image.split(',', 1)[0], 'base64'); if (this.returnMetadata && this.uploadImageBuffer && this.req) { const file = await this.uploadImageBuffer({ @@ -154,7 +188,7 @@ class StableDiffusionAPI extends Tool { logger.error('[StableDiffusion] Error while saving the image:', error); } - return this.result; + return this.returnValue(this.result); } } diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index f1dfa24a49c..ae19a158eec 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -10,6 +10,7 @@ const { GoogleSearchAPI, // Structured Tools DALLE3, + FluxAPI, OpenWeather, StructuredSD, StructuredACS, @@ -182,6 +183,7 @@ const loadTools = async ({ returnMap = false, }) => { const toolConstructors = { + flux: FluxAPI, calculator: Calculator, google: GoogleSearchAPI, open_weather: OpenWeather, @@ -230,9 +232,10 @@ const loadTools = async ({ }; const toolOptions = { - serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' }, + flux: imageGenOptions, dalle: imageGenOptions, 'stable-diffusion': imageGenOptions, + serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' }, }; const toolContextMap = {}; diff --git a/api/lib/db/indexSync.js b/api/lib/db/indexSync.js index 9c40e684d30..75acd9d2318 100644 --- a/api/lib/db/indexSync.js +++ b/api/lib/db/indexSync.js @@ -1,6 +1,6 @@ const { MeiliSearch } = require('meilisearch'); -const Conversation = require('~/models/schema/convoSchema'); -const Message = require('~/models/schema/messageSchema'); +const { Conversation } = require('~/models/Conversation'); +const { Message } = require('~/models/Message'); const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); diff --git a/api/models/Action.js b/api/models/Action.js index 299b3bf20a3..677b4d78df3 100644 --- a/api/models/Action.js +++ b/api/models/Action.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const actionSchema = require('./schema/action'); +const { actionSchema } = require('@librechat/data-schemas'); const Action = mongoose.model('action', actionSchema); diff --git a/api/models/Agent.js b/api/models/Agent.js index 6ea203113c0..1d3ea5af0c3 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -9,7 +9,7 @@ const { removeAgentFromAllProjects, } = require('./Project'); const getLogStores = require('~/cache/getLogStores'); -const agentSchema = require('./schema/agent'); +const { agentSchema } = require('@librechat/data-schemas'); const Agent = mongoose.model('agent', agentSchema); diff --git a/api/models/Assistant.js b/api/models/Assistant.js index d0e73ad4e7b..a8a5b981576 100644 --- a/api/models/Assistant.js +++ b/api/models/Assistant.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const assistantSchema = require('./schema/assistant'); +const { assistantSchema } = require('@librechat/data-schemas'); const Assistant = mongoose.model('assistant', assistantSchema); diff --git a/api/models/Balance.js b/api/models/Balance.js index 24d9087b77f..f7978d80498 100644 --- a/api/models/Balance.js +++ b/api/models/Balance.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const balanceSchema = require('./schema/balance'); +const { balanceSchema } = require('@librechat/data-schemas'); const { getMultiplier } = require('./tx'); const { logger } = require('~/config'); diff --git a/api/models/Banner.js b/api/models/Banner.js index 8d439dae289..0f20faeba84 100644 --- a/api/models/Banner.js +++ b/api/models/Banner.js @@ -1,5 +1,9 @@ -const Banner = require('./schema/banner'); +const mongoose = require('mongoose'); const logger = require('~/config/winston'); +const { bannerSchema } = require('@librechat/data-schemas'); + +const Banner = mongoose.model('Banner', bannerSchema); + /** * Retrieves the current active banner. * @returns {Promise} The active banner object or null if no active banner is found. diff --git a/api/models/Categories.js b/api/models/Categories.js index 6fb88fb995e..5da1f4b2da5 100644 --- a/api/models/Categories.js +++ b/api/models/Categories.js @@ -1,5 +1,4 @@ const { logger } = require('~/config'); -// const { Categories } = require('./schema/categories'); const options = [ { diff --git a/api/models/Conversation.js b/api/models/Conversation.js index d6365e99ce4..9e51926ebcc 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -104,10 +104,16 @@ module.exports = { update.expiredAt = null; } + /** @type {{ $set: Partial; $unset?: Record }} */ + const updateOperation = { $set: update }; + if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) { + updateOperation.$unset = metadata.unsetFields; + } + /** Note: the resulting Model object is necessary for Meilisearch operations */ const conversation = await Conversation.findOneAndUpdate( { conversationId, user: req.user.id }, - update, + updateOperation, { new: true, upsert: true, diff --git a/api/models/ConversationTag.js b/api/models/ConversationTag.js index 53d144e1f50..f0cac8620e7 100644 --- a/api/models/ConversationTag.js +++ b/api/models/ConversationTag.js @@ -1,7 +1,11 @@ -const ConversationTag = require('./schema/conversationTagSchema'); +const mongoose = require('mongoose'); const Conversation = require('./schema/convoSchema'); const logger = require('~/config/winston'); +const { conversationTagSchema } = require('@librechat/data-schemas'); + +const ConversationTag = mongoose.model('ConversationTag', conversationTagSchema); + /** * Retrieves all conversation tags for a user. * @param {string} user - The user ID. diff --git a/api/models/File.js b/api/models/File.js index 17f85066002..870a18a7c8e 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const fileSchema = require('./schema/fileSchema'); +const { fileSchema } = require('@librechat/data-schemas'); const File = mongoose.model('File', fileSchema); @@ -7,7 +7,7 @@ const File = mongoose.model('File', fileSchema); * Finds a file by its file_id with additional query options. * @param {string} file_id - The unique identifier of the file. * @param {object} options - Query options for filtering, projection, etc. - * @returns {Promise} A promise that resolves to the file document or null. + * @returns {Promise} A promise that resolves to the file document or null. */ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); @@ -17,7 +17,7 @@ const findFileById = async (file_id, options = {}) => { * Retrieves files matching a given filter, sorted by the most recently updated. * @param {Object} filter - The filter criteria to apply. * @param {Object} [_sortOptions] - Optional sort parameters. - * @returns {Promise>} A promise that resolves to an array of file documents. + * @returns {Promise>} A promise that resolves to an array of file documents. */ const getFiles = async (filter, _sortOptions) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; @@ -26,9 +26,9 @@ const getFiles = async (filter, _sortOptions) => { /** * Creates a new file with a TTL of 1 hour. - * @param {MongoFile} data - The file data to be created, must contain file_id. + * @param {IMongoFile} data - The file data to be created, must contain file_id. * @param {boolean} disableTTL - Whether to disable the TTL. - * @returns {Promise} A promise that resolves to the created file document. + * @returns {Promise} A promise that resolves to the created file document. */ const createFile = async (data, disableTTL) => { const fileData = { @@ -48,8 +48,8 @@ const createFile = async (data, disableTTL) => { /** * Updates a file identified by file_id with new data and removes the TTL. - * @param {MongoFile} data - The data to update, must contain file_id. - * @returns {Promise} A promise that resolves to the updated file document. + * @param {IMongoFile} data - The data to update, must contain file_id. + * @returns {Promise} A promise that resolves to the updated file document. */ const updateFile = async (data) => { const { file_id, ...update } = data; @@ -62,8 +62,8 @@ const updateFile = async (data) => { /** * Increments the usage of a file identified by file_id. - * @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage. - * @returns {Promise} A promise that resolves to the updated file document. + * @param {IMongoFile} data - The data to update, must contain file_id and the increment value for usage. + * @returns {Promise} A promise that resolves to the updated file document. */ const updateFileUsage = async (data) => { const { file_id, inc = 1 } = data; @@ -77,7 +77,7 @@ const updateFileUsage = async (data) => { /** * Deletes a file identified by file_id. * @param {string} file_id - The unique identifier of the file to delete. - * @returns {Promise} A promise that resolves to the deleted file document or null. + * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFile = async (file_id) => { return await File.findOneAndDelete({ file_id }).lean(); @@ -86,7 +86,7 @@ const deleteFile = async (file_id) => { /** * Deletes a file identified by a filter. * @param {object} filter - The filter criteria to apply. - * @returns {Promise} A promise that resolves to the deleted file document or null. + * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFileByFilter = async (filter) => { return await File.findOneAndDelete(filter).lean(); diff --git a/api/models/Key.js b/api/models/Key.js index 58fb0ac3a97..c69c350a42c 100644 --- a/api/models/Key.js +++ b/api/models/Key.js @@ -1,4 +1,4 @@ const mongoose = require('mongoose'); -const keySchema = require('./schema/key'); +const { keySchema } = require('@librechat/data-schemas'); module.exports = mongoose.model('Key', keySchema); diff --git a/api/models/Project.js b/api/models/Project.js index 17ef3093a52..43d72637236 100644 --- a/api/models/Project.js +++ b/api/models/Project.js @@ -1,6 +1,6 @@ const { model } = require('mongoose'); const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; -const projectSchema = require('~/models/schema/projectSchema'); +const { projectSchema } = require('@librechat/data-schemas'); const Project = model('Project', projectSchema); @@ -9,7 +9,7 @@ const Project = model('Project', projectSchema); * * @param {string} projectId - The ID of the project to find and return as a plain object. * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the project document, or `null` if no project is found. + * @returns {Promise} A plain object representing the project document, or `null` if no project is found. */ const getProjectById = async function (projectId, fieldsToSelect = null) { const query = Project.findById(projectId); @@ -27,7 +27,7 @@ const getProjectById = async function (projectId, fieldsToSelect = null) { * * @param {string} projectName - The name of the project to find or create. * @param {string|string[]} [fieldsToSelect] - The fields to include or exclude in the returned document. - * @returns {Promise} A plain object representing the project document. + * @returns {Promise} A plain object representing the project document. */ const getProjectByName = async function (projectName, fieldsToSelect = null) { const query = { name: projectName }; @@ -47,7 +47,7 @@ const getProjectByName = async function (projectName, fieldsToSelect = null) { * * @param {string} projectId - The ID of the project to update. * @param {string[]} promptGroupIds - The array of prompt group IDs to add to the project. - * @returns {Promise} The updated project document. + * @returns {Promise} The updated project document. */ const addGroupIdsToProject = async function (projectId, promptGroupIds) { return await Project.findByIdAndUpdate( @@ -62,7 +62,7 @@ const addGroupIdsToProject = async function (projectId, promptGroupIds) { * * @param {string} projectId - The ID of the project to update. * @param {string[]} promptGroupIds - The array of prompt group IDs to remove from the project. - * @returns {Promise} The updated project document. + * @returns {Promise} The updated project document. */ const removeGroupIdsFromProject = async function (projectId, promptGroupIds) { return await Project.findByIdAndUpdate( @@ -87,7 +87,7 @@ const removeGroupFromAllProjects = async (promptGroupId) => { * * @param {string} projectId - The ID of the project to update. * @param {string[]} agentIds - The array of agent IDs to add to the project. - * @returns {Promise} The updated project document. + * @returns {Promise} The updated project document. */ const addAgentIdsToProject = async function (projectId, agentIds) { return await Project.findByIdAndUpdate( @@ -102,7 +102,7 @@ const addAgentIdsToProject = async function (projectId, agentIds) { * * @param {string} projectId - The ID of the project to update. * @param {string[]} agentIds - The array of agent IDs to remove from the project. - * @returns {Promise} The updated project document. + * @returns {Promise} The updated project document. */ const removeAgentIdsFromProject = async function (projectId, agentIds) { return await Project.findByIdAndUpdate( diff --git a/api/models/Prompt.js b/api/models/Prompt.js index 60456884a88..43dc3ec22b9 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -1,3 +1,4 @@ +const mongoose = require('mongoose'); const { ObjectId } = require('mongodb'); const { SystemRoles, SystemCategories, Constants } = require('librechat-data-provider'); const { @@ -6,10 +7,13 @@ const { removeGroupIdsFromProject, removeGroupFromAllProjects, } = require('./Project'); -const { Prompt, PromptGroup } = require('./schema/promptSchema'); +const { promptGroupSchema, promptSchema } = require('@librechat/data-schemas'); const { escapeRegExp } = require('~/server/utils'); const { logger } = require('~/config'); +const PromptGroup = mongoose.model('PromptGroup', promptGroupSchema); +const Prompt = mongoose.model('Prompt', promptSchema); + /** * Create a pipeline for the aggregation to get prompt groups * @param {Object} query diff --git a/api/models/Role.js b/api/models/Role.js index 3f025707184..4be5faeadb2 100644 --- a/api/models/Role.js +++ b/api/models/Role.js @@ -1,3 +1,4 @@ +const mongoose = require('mongoose'); const { CacheKeys, SystemRoles, @@ -12,9 +13,11 @@ const { temporaryChatPermissionsSchema, } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const Role = require('~/models/schema/roleSchema'); +const { roleSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); +const Role = mongoose.model('Role', roleSchema); + /** * Retrieve a role by name and convert the found role document to a plain object. * If the role with the given name doesn't exist and the name is a system defined role, create it and return the lean version. @@ -168,6 +171,7 @@ const initializeRoles = async function () { } }; module.exports = { + Role, getRoleByName, initializeRoles, updateRoleByName, diff --git a/api/models/Role.spec.js b/api/models/Role.spec.js index 92386f0fa91..39611f7b959 100644 --- a/api/models/Role.spec.js +++ b/api/models/Role.spec.js @@ -8,7 +8,7 @@ const { } = require('librechat-data-provider'); const { updateAccessPermissions, initializeRoles } = require('~/models/Role'); const getLogStores = require('~/cache/getLogStores'); -const Role = require('~/models/schema/roleSchema'); +const { Role } = require('~/models/Role'); // Mock the cache jest.mock('~/cache/getLogStores', () => { diff --git a/api/models/Session.js b/api/models/Session.js index dbb66ed8ff6..38821b77dd3 100644 --- a/api/models/Session.js +++ b/api/models/Session.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const signPayload = require('~/server/services/signPayload'); const { hashToken } = require('~/server/utils/crypto'); -const sessionSchema = require('./schema/session'); +const { sessionSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); const Session = mongoose.model('Session', sessionSchema); diff --git a/api/models/Share.js b/api/models/Share.js index 041927ec616..a8bfbce7fed 100644 --- a/api/models/Share.js +++ b/api/models/Share.js @@ -1,7 +1,9 @@ +const mongoose = require('mongoose'); const { nanoid } = require('nanoid'); const { Constants } = require('librechat-data-provider'); const { Conversation } = require('~/models/Conversation'); -const SharedLink = require('./schema/shareSchema'); +const { shareSchema } = require('@librechat/data-schemas'); +const SharedLink = mongoose.model('SharedLink', shareSchema); const { getMessages } = require('./Message'); const logger = require('~/config/winston'); diff --git a/api/models/Token.js b/api/models/Token.js index 210666ddd78..c89abb8c841 100644 --- a/api/models/Token.js +++ b/api/models/Token.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const { encryptV2 } = require('~/server/utils/crypto'); -const tokenSchema = require('./schema/tokenSchema'); +const { tokenSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); /** @@ -13,6 +13,13 @@ const Token = mongoose.model('Token', tokenSchema); */ async function fixIndexes() { try { + if ( + process.env.NODE_ENV === 'CI' || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'test' + ) { + return; + } const indexes = await Token.collection.indexes(); logger.debug('Existing Token Indexes:', JSON.stringify(indexes, null, 2)); const unwantedTTLIndexes = indexes.filter( diff --git a/api/models/ToolCall.js b/api/models/ToolCall.js index e1d7b0cc842..7bc0f157dc7 100644 --- a/api/models/ToolCall.js +++ b/api/models/ToolCall.js @@ -1,9 +1,11 @@ -const ToolCall = require('./schema/toolCallSchema'); +const mongoose = require('mongoose'); +const { toolCallSchema } = require('@librechat/data-schemas'); +const ToolCall = mongoose.model('ToolCall', toolCallSchema); /** * Create a new tool call - * @param {ToolCallData} toolCallData - The tool call data - * @returns {Promise} The created tool call document + * @param {IToolCallData} toolCallData - The tool call data + * @returns {Promise} The created tool call document */ async function createToolCall(toolCallData) { try { @@ -16,7 +18,7 @@ async function createToolCall(toolCallData) { /** * Get a tool call by ID * @param {string} id - The tool call document ID - * @returns {Promise} The tool call document or null if not found + * @returns {Promise} The tool call document or null if not found */ async function getToolCallById(id) { try { @@ -44,7 +46,7 @@ async function getToolCallsByMessage(messageId, userId) { * Get tool calls by conversation ID and user * @param {string} conversationId - The conversation ID * @param {string} userId - The user's ObjectId - * @returns {Promise} Array of tool call documents + * @returns {Promise} Array of tool call documents */ async function getToolCallsByConvo(conversationId, userId) { try { @@ -57,8 +59,8 @@ async function getToolCallsByConvo(conversationId, userId) { /** * Update a tool call * @param {string} id - The tool call document ID - * @param {Partial} updateData - The data to update - * @returns {Promise} The updated tool call document or null if not found + * @param {Partial} updateData - The data to update + * @returns {Promise} The updated tool call document or null if not found */ async function updateToolCall(id, updateData) { try { diff --git a/api/models/Transaction.js b/api/models/Transaction.js index 8435a812c4a..b1c4c657106 100644 --- a/api/models/Transaction.js +++ b/api/models/Transaction.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const { isEnabled } = require('~/server/utils/handleText'); -const transactionSchema = require('./schema/transaction'); +const { transactionSchema } = require('@librechat/data-schemas'); const { getMultiplier, getCacheMultiplier } = require('./tx'); const { logger } = require('~/config'); const Balance = require('./Balance'); diff --git a/api/models/User.js b/api/models/User.js index 55750b4ae56..f4e8b0ec5b0 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const userSchema = require('~/models/schema/userSchema'); +const { userSchema } = require('@librechat/data-schemas'); const User = mongoose.model('User', userSchema); diff --git a/api/models/plugins/mongoMeili.js b/api/models/plugins/mongoMeili.js index df96338302b..6577370b1e9 100644 --- a/api/models/plugins/mongoMeili.js +++ b/api/models/plugins/mongoMeili.js @@ -4,9 +4,28 @@ const { MeiliSearch } = require('meilisearch'); const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); const logger = require('~/config/meiliLogger'); +// Environment flags +/** + * Flag to indicate if search is enabled based on environment variables. + * @type {boolean} + */ const searchEnabled = process.env.SEARCH && process.env.SEARCH.toLowerCase() === 'true'; + +/** + * Flag to indicate if MeiliSearch is enabled based on required environment variables. + * @type {boolean} + */ const meiliEnabled = process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY && searchEnabled; +/** + * Validates the required options for configuring the mongoMeili plugin. + * + * @param {Object} options - The configuration options. + * @param {string} options.host - The MeiliSearch host. + * @param {string} options.apiKey - The MeiliSearch API key. + * @param {string} options.indexName - The name of the index. + * @throws {Error} Throws an error if any required option is missing. + */ const validateOptions = function (options) { const requiredKeys = ['host', 'apiKey', 'indexName']; requiredKeys.forEach((key) => { @@ -16,53 +35,64 @@ const validateOptions = function (options) { }); }; -// const createMeiliMongooseModel = function ({ index, indexName, client, attributesToIndex }) { +/** + * Factory function to create a MeiliMongooseModel class which extends a Mongoose model. + * This class contains static and instance methods to synchronize and manage the MeiliSearch index + * corresponding to the MongoDB collection. + * + * @param {Object} config - Configuration object. + * @param {Object} config.index - The MeiliSearch index object. + * @param {Array} config.attributesToIndex - List of attributes to index. + * @returns {Function} A class definition that will be loaded into the Mongoose schema. + */ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { + // The primary key is assumed to be the first attribute in the attributesToIndex array. const primaryKey = attributesToIndex[0]; - // MeiliMongooseModel is of type Mongoose.Model + class MeiliMongooseModel { /** - * `syncWithMeili`: synchronizes the data between a MongoDB collection and a MeiliSearch index, - * only triggered if there's ever a discrepancy determined by `api\lib\db\indexSync.js`. + * Synchronizes the data between the MongoDB collection and the MeiliSearch index. * - * 1. Fetches all documents from the MongoDB collection and the MeiliSearch index. - * 2. Compares the documents from both sources. - * 3. If a document exists in MeiliSearch but not in MongoDB, it's deleted from MeiliSearch. - * 4. If a document exists in MongoDB but not in MeiliSearch, it's added to MeiliSearch. - * 5. If a document exists in both but has different `text` or `title` fields (depending on the `primaryKey`), it's updated in MeiliSearch. - * 6. After all operations, it updates the `_meiliIndex` field in MongoDB to indicate whether the document is indexed in MeiliSearch. + * The synchronization process involves: + * 1. Fetching all documents from the MongoDB collection and MeiliSearch index. + * 2. Comparing documents from both sources. + * 3. Deleting documents from MeiliSearch that no longer exist in MongoDB. + * 4. Adding documents to MeiliSearch that exist in MongoDB but not in the index. + * 5. Updating documents in MeiliSearch if key fields (such as `text` or `title`) differ. + * 6. Updating the `_meiliIndex` field in MongoDB to indicate the indexing status. * - * Note: This strategy does not use batch operations for Meilisearch as the `index.addDocuments` will discard - * the entire batch if there's an error with one document, and will not throw an error if there's an issue. - * Also, `index.getDocuments` needs an exact limit on the amount of documents to return, so we build the map in batches. + * Note: The function processes documents in batches because MeiliSearch's + * `index.getDocuments` requires an exact limit and `index.addDocuments` does not handle + * partial failures in a batch. * - * @returns {Promise} A promise that resolves when the synchronization is complete. - * - * @throws {Error} Throws an error if there's an issue with adding a document to MeiliSearch. + * @returns {Promise} Resolves when the synchronization is complete. */ static async syncWithMeili() { try { let moreDocuments = true; + // Retrieve all MongoDB documents from the collection as plain JavaScript objects. const mongoDocuments = await this.find().lean(); - const format = (doc) => _.pick(doc, attributesToIndex); - // Prepare for comparison + // Helper function to format a document by selecting only the attributes to index + // and omitting keys starting with '$'. + const format = (doc) => + _.omitBy(_.pick(doc, attributesToIndex), (v, k) => k.startsWith('$')); + + // Build a map of MongoDB documents for quick lookup based on the primary key. const mongoMap = new Map(mongoDocuments.map((doc) => [doc[primaryKey], format(doc)])); const indexMap = new Map(); let offset = 0; const batchSize = 1000; + // Fetch documents from the MeiliSearch index in batches. while (moreDocuments) { const batch = await index.getDocuments({ limit: batchSize, offset }); - if (batch.results.length === 0) { moreDocuments = false; } - for (const doc of batch.results) { indexMap.set(doc[primaryKey], format(doc)); } - offset += batchSize; } @@ -70,13 +100,12 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { const updateOps = []; - // Iterate over Meili index documents + // Process documents present in the MeiliSearch index. for (const [id, doc] of indexMap) { const update = {}; update[primaryKey] = id; if (mongoMap.has(id)) { - // Case: Update - // If document also exists in MongoDB, would be update case + // If document exists in MongoDB, check for discrepancies in key fields. if ( (doc.text && doc.text !== mongoMap.get(id).text) || (doc.title && doc.title !== mongoMap.get(id).title) @@ -92,8 +121,7 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { await index.addDocuments([doc]); } } else { - // Case: Delete - // If document does not exist in MongoDB, its a delete case from meili index + // If the document does not exist in MongoDB, delete it from MeiliSearch. await index.deleteDocument(id); updateOps.push({ updateOne: { filter: update, update: { $set: { _meiliIndex: false } } }, @@ -101,24 +129,25 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { } } - // Iterate over MongoDB documents + // Process documents present in MongoDB. for (const [id, doc] of mongoMap) { const update = {}; update[primaryKey] = id; - // Case: Insert - // If document does not exist in Meili Index, Its an insert case + // If the document is missing in the Meili index, add it. if (!indexMap.has(id)) { await index.addDocuments([doc]); updateOps.push({ updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, }); } else if (doc._meiliIndex === false) { + // If the document exists but is marked as not indexed, update the flag. updateOps.push({ updateOne: { filter: update, update: { $set: { _meiliIndex: true } } }, }); } } + // Execute bulk update operations in MongoDB to update the _meiliIndex flags. if (updateOps.length > 0) { await this.collection.bulkWrite(updateOps); logger.debug( @@ -132,34 +161,47 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { } } - // Set one or more settings of the meili index + /** + * Updates settings for the MeiliSearch index. + * + * @param {Object} settings - The settings to update on the MeiliSearch index. + * @returns {Promise} Promise resolving to the update result. + */ static async setMeiliIndexSettings(settings) { return await index.updateSettings(settings); } - // Search the index + /** + * Searches the MeiliSearch index and optionally populates the results with data from MongoDB. + * + * @param {string} q - The search query. + * @param {Object} params - Additional search parameters for MeiliSearch. + * @param {boolean} populate - Whether to populate search hits with full MongoDB documents. + * @returns {Promise} The search results with populated hits if requested. + */ static async meiliSearch(q, params, populate) { const data = await index.search(q, params); - // Populate hits with content from mongodb if (populate) { - // Find objects into mongodb matching `objectID` from Meili search + // Build a query using the primary key values from the search hits. const query = {}; - // query[primaryKey] = { $in: _.map(data.hits, primaryKey) }; query[primaryKey] = _.map(data.hits, (hit) => cleanUpPrimaryKeyValue(hit[primaryKey])); - // logger.debug('query', query); - const hitsFromMongoose = await this.find( - query, - _.reduce( - this.schema.obj, - function (results, value, key) { - return { ...results, [key]: 1 }; - }, - { _id: 1, __v: 1 }, - ), - ).lean(); - - // Add additional data from mongodb into Meili search hits + + // Build a projection object, including only keys that do not start with '$'. + const projection = Object.keys(this.schema.obj).reduce( + (results, key) => { + if (!key.startsWith('$')) { + results[key] = 1; + } + return results; + }, + { _id: 1, __v: 1 }, + ); + + // Retrieve the full documents from MongoDB. + const hitsFromMongoose = await this.find(query, projection).lean(); + + // Merge the MongoDB documents with the search hits. const populatedHits = data.hits.map(function (hit) { const query = {}; query[primaryKey] = hit[primaryKey]; @@ -176,10 +218,21 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { return data; } + /** + * Preprocesses the current document for indexing. + * + * This method: + * - Picks only the defined attributes to index. + * - Omits any keys starting with '$'. + * - Replaces pipe characters ('|') in `conversationId` with '--'. + * - Extracts and concatenates text from an array of content items. + * + * @returns {Object} The preprocessed object ready for indexing. + */ preprocessObjectForIndex() { - const object = _.pick(this.toJSON(), attributesToIndex); - // NOTE: MeiliSearch does not allow | in primary key, so we replace it with - for Bing convoIds - // object.conversationId = object.conversationId.replace(/\|/g, '-'); + const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) => + k.startsWith('$'), + ); if (object.conversationId && object.conversationId.includes('|')) { object.conversationId = object.conversationId.replace(/\|/g, '--'); } @@ -195,32 +248,53 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { return object; } - // Push new document to Meili + /** + * Adds the current document to the MeiliSearch index. + * + * The method preprocesses the document, adds it to MeiliSearch, and then updates + * the MongoDB document's `_meiliIndex` flag to true. + * + * @returns {Promise} + */ async addObjectToMeili() { const object = this.preprocessObjectForIndex(); try { - // logger.debug('Adding document to Meili', object); await index.addDocuments([object]); } catch (error) { - // logger.debug('Error adding document to Meili'); - // logger.error(error); + // Error handling can be enhanced as needed. + logger.error('[addObjectToMeili] Error adding document to Meili', error); } await this.collection.updateMany({ _id: this._id }, { $set: { _meiliIndex: true } }); } - // Update an existing document in Meili + /** + * Updates the current document in the MeiliSearch index. + * + * @returns {Promise} + */ async updateObjectToMeili() { - const object = _.pick(this.toJSON(), attributesToIndex); + const object = _.omitBy(_.pick(this.toJSON(), attributesToIndex), (v, k) => + k.startsWith('$'), + ); await index.updateDocuments([object]); } - // Delete a document from Meili + /** + * Deletes the current document from the MeiliSearch index. + * + * @returns {Promise} + */ async deleteObjectFromMeili() { await index.deleteDocument(this._id); } - // * schema.post('save') + /** + * Post-save hook to synchronize the document with MeiliSearch. + * + * If the document is already indexed (i.e. `_meiliIndex` is true), it updates it; + * otherwise, it adds the document to the index. + */ postSaveHook() { if (this._meiliIndex) { this.updateObjectToMeili(); @@ -229,14 +303,24 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { } } - // * schema.post('update') + /** + * Post-update hook to update the document in MeiliSearch. + * + * This hook is triggered after a document update, ensuring that changes are + * propagated to the MeiliSearch index if the document is indexed. + */ postUpdateHook() { if (this._meiliIndex) { this.updateObjectToMeili(); } } - // * schema.post('remove') + /** + * Post-remove hook to delete the document from MeiliSearch. + * + * This hook is triggered after a document is removed, ensuring that the document + * is also removed from the MeiliSearch index if it was previously indexed. + */ postRemoveHook() { if (this._meiliIndex) { this.deleteObjectFromMeili(); @@ -247,11 +331,27 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) { return MeiliMongooseModel; }; +/** + * Mongoose plugin to synchronize MongoDB collections with a MeiliSearch index. + * + * This plugin: + * - Validates the provided options. + * - Adds a `_meiliIndex` field to the schema to track indexing status. + * - Sets up a MeiliSearch client and creates an index if it doesn't already exist. + * - Loads class methods for syncing, searching, and managing documents in MeiliSearch. + * - Registers Mongoose hooks (post-save, post-update, post-remove, etc.) to maintain index consistency. + * + * @param {mongoose.Schema} schema - The Mongoose schema to which the plugin is applied. + * @param {Object} options - Configuration options. + * @param {string} options.host - The MeiliSearch host. + * @param {string} options.apiKey - The MeiliSearch API key. + * @param {string} options.indexName - The name of the MeiliSearch index. + * @param {string} options.primaryKey - The primary key field for indexing. + */ module.exports = function mongoMeili(schema, options) { - // Vaidate Options for mongoMeili validateOptions(options); - // Add meiliIndex to schema + // Add _meiliIndex field to the schema to track if a document has been indexed in MeiliSearch. schema.add({ _meiliIndex: { type: Boolean, @@ -263,69 +363,77 @@ module.exports = function mongoMeili(schema, options) { const { host, apiKey, indexName, primaryKey } = options; - // Setup MeiliSearch Client + // Setup the MeiliSearch client. const client = new MeiliSearch({ host, apiKey }); - // Asynchronously create the index + // Create the index asynchronously if it doesn't exist. client.createIndex(indexName, { primaryKey }); - // Setup the index to search for this schema + // Setup the MeiliSearch index for this schema. const index = client.index(indexName); + // Collect attributes from the schema that should be indexed. const attributesToIndex = [ ..._.reduce( schema.obj, function (results, value, key) { return value.meiliIndex ? [...results, key] : results; - // }, []), '_id']; }, [], ), ]; + // Load the class methods into the schema. schema.loadClass(createMeiliMongooseModel({ index, indexName, client, attributesToIndex })); - // Register hooks + // Register Mongoose hooks to synchronize with MeiliSearch. + + // Post-save: synchronize after a document is saved. schema.post('save', function (doc) { doc.postSaveHook(); }); + + // Post-update: synchronize after a document is updated. schema.post('update', function (doc) { doc.postUpdateHook(); }); + + // Post-remove: synchronize after a document is removed. schema.post('remove', function (doc) { doc.postRemoveHook(); }); + // Pre-deleteMany hook: remove corresponding documents from MeiliSearch when multiple documents are deleted. schema.pre('deleteMany', async function (next) { if (!meiliEnabled) { - next(); + return next(); } try { + // Check if the schema has a "messages" field to determine if it's a conversation schema. if (Object.prototype.hasOwnProperty.call(schema.obj, 'messages')) { const convoIndex = client.index('convos'); const deletedConvos = await mongoose.model('Conversation').find(this._conditions).lean(); - let promises = []; - for (const convo of deletedConvos) { - promises.push(convoIndex.deleteDocument(convo.conversationId)); - } + const promises = deletedConvos.map((convo) => + convoIndex.deleteDocument(convo.conversationId), + ); await Promise.all(promises); } + // Check if the schema has a "messageId" field to determine if it's a message schema. if (Object.prototype.hasOwnProperty.call(schema.obj, 'messageId')) { const messageIndex = client.index('messages'); const deletedMessages = await mongoose.model('Message').find(this._conditions).lean(); - let promises = []; - for (const message of deletedMessages) { - promises.push(messageIndex.deleteDocument(message.messageId)); - } + const promises = deletedMessages.map((message) => + messageIndex.deleteDocument(message.messageId), + ); await Promise.all(promises); } return next(); } catch (error) { if (meiliEnabled) { logger.error( - '[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion, next startup may be slow due to syncing', + '[MeiliMongooseModel.deleteMany] There was an issue deleting conversation indexes upon deletion. Next startup may be slow due to syncing.', error, ); } @@ -333,17 +441,19 @@ module.exports = function mongoMeili(schema, options) { } }); + // Post-findOneAndUpdate hook: update MeiliSearch index after a document is updated via findOneAndUpdate. schema.post('findOneAndUpdate', async function (doc) { if (!meiliEnabled) { return; } + // If the document is unfinished, do not update the index. if (doc.unfinished) { return; } let meiliDoc; - // Doc is a Conversation + // For conversation documents, try to fetch the document from the "convos" index. if (doc.messages) { try { meiliDoc = await client.index('convos').getDocument(doc.conversationId); @@ -356,10 +466,12 @@ module.exports = function mongoMeili(schema, options) { } } + // If the MeiliSearch document exists and the title is unchanged, do nothing. if (meiliDoc && meiliDoc.title === doc.title) { return; } + // Otherwise, trigger a post-save hook to synchronize the document. doc.postSaveHook(); }); }; diff --git a/api/models/schema/action.js b/api/models/schema/action.js deleted file mode 100644 index f86a9bfa2d0..00000000000 --- a/api/models/schema/action.js +++ /dev/null @@ -1,60 +0,0 @@ -const mongoose = require('mongoose'); - -const { Schema } = mongoose; - -const AuthSchema = new Schema( - { - authorization_type: String, - custom_auth_header: String, - type: { - type: String, - enum: ['service_http', 'oauth', 'none'], - }, - authorization_content_type: String, - authorization_url: String, - client_url: String, - scope: String, - token_exchange_method: { - type: String, - enum: ['default_post', 'basic_auth_header', null], - }, - }, - { _id: false }, -); - -const actionSchema = new Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - index: true, - required: true, - }, - action_id: { - type: String, - index: true, - required: true, - }, - type: { - type: String, - default: 'action_prototype', - }, - settings: Schema.Types.Mixed, - agent_id: String, - assistant_id: String, - metadata: { - api_key: String, // private, encrypted - auth: AuthSchema, - domain: { - type: String, - required: true, - }, - // json_schema: Schema.Types.Mixed, - privacy_policy_url: String, - raw_spec: String, - oauth_client_id: String, // private, encrypted - oauth_client_secret: String, // private, encrypted - }, -}); -// }, { minimize: false }); // Prevent removal of empty objects - -module.exports = actionSchema; diff --git a/api/models/schema/balance.js b/api/models/schema/balance.js deleted file mode 100644 index 8ca8116e09b..00000000000 --- a/api/models/schema/balance.js +++ /dev/null @@ -1,17 +0,0 @@ -const mongoose = require('mongoose'); - -const balanceSchema = mongoose.Schema({ - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - index: true, - required: true, - }, - // 1000 tokenCredits = 1 mill ($0.001 USD) - tokenCredits: { - type: Number, - default: 0, - }, -}); - -module.exports = balanceSchema; diff --git a/api/models/schema/categories.js b/api/models/schema/categories.js deleted file mode 100644 index 31676856670..00000000000 --- a/api/models/schema/categories.js +++ /dev/null @@ -1,19 +0,0 @@ -const mongoose = require('mongoose'); -const Schema = mongoose.Schema; - -const categoriesSchema = new Schema({ - label: { - type: String, - required: true, - unique: true, - }, - value: { - type: String, - required: true, - unique: true, - }, -}); - -const categories = mongoose.model('categories', categoriesSchema); - -module.exports = { Categories: categories }; diff --git a/api/models/schema/conversationTagSchema.js b/api/models/schema/conversationTagSchema.js deleted file mode 100644 index 9b2a98c6d83..00000000000 --- a/api/models/schema/conversationTagSchema.js +++ /dev/null @@ -1,32 +0,0 @@ -const mongoose = require('mongoose'); - -const conversationTagSchema = mongoose.Schema( - { - tag: { - type: String, - index: true, - }, - user: { - type: String, - index: true, - }, - description: { - type: String, - index: true, - }, - count: { - type: Number, - default: 0, - }, - position: { - type: Number, - default: 0, - index: true, - }, - }, - { timestamps: true }, -); - -conversationTagSchema.index({ tag: 1, user: 1 }, { unique: true }); - -module.exports = mongoose.model('ConversationTag', conversationTagSchema); diff --git a/api/models/schema/convoSchema.js b/api/models/schema/convoSchema.js index 7d8beed6a6c..89cb9c80b5d 100644 --- a/api/models/schema/convoSchema.js +++ b/api/models/schema/convoSchema.js @@ -1,63 +1,18 @@ const mongoose = require('mongoose'); const mongoMeili = require('../plugins/mongoMeili'); -const { conversationPreset } = require('./defaults'); -const convoSchema = mongoose.Schema( - { - conversationId: { - type: String, - unique: true, - required: true, - index: true, - meiliIndex: true, - }, - title: { - type: String, - default: 'New Chat', - meiliIndex: true, - }, - user: { - type: String, - index: true, - }, - messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }], - // google only - examples: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, - agentOptions: { - type: mongoose.Schema.Types.Mixed, - }, - ...conversationPreset, - agent_id: { - type: String, - }, - tags: { - type: [String], - default: [], - meiliIndex: true, - }, - files: { - type: [String], - }, - expiredAt: { - type: Date, - }, - }, - { timestamps: true }, -); + +const { convoSchema } = require('@librechat/data-schemas'); if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { convoSchema.plugin(mongoMeili, { host: process.env.MEILI_HOST, apiKey: process.env.MEILI_MASTER_KEY, - indexName: 'convos', // Will get created automatically if it doesn't exist already + /** Note: Will get created automatically if it doesn't exist already */ + indexName: 'convos', primaryKey: 'conversationId', }); } -// Create TTL index -convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); -convoSchema.index({ createdAt: 1, updatedAt: 1 }); -convoSchema.index({ conversationId: 1, user: 1 }, { unique: true }); - const Conversation = mongoose.models.Conversation || mongoose.model('Conversation', convoSchema); module.exports = Conversation; diff --git a/api/models/schema/fileSchema.js b/api/models/schema/fileSchema.js deleted file mode 100644 index 77c6ff94d4a..00000000000 --- a/api/models/schema/fileSchema.js +++ /dev/null @@ -1,111 +0,0 @@ -const { FileSources } = require('librechat-data-provider'); -const mongoose = require('mongoose'); - -/** - * @typedef {Object} MongoFile - * @property {ObjectId} [_id] - MongoDB Document ID - * @property {number} [__v] - MongoDB Version Key - * @property {ObjectId} user - User ID - * @property {string} [conversationId] - Optional conversation ID - * @property {string} file_id - File identifier - * @property {string} [temp_file_id] - Temporary File identifier - * @property {number} bytes - Size of the file in bytes - * @property {string} filename - Name of the file - * @property {string} filepath - Location of the file - * @property {'file'} object - Type of object, always 'file' - * @property {string} type - Type of file - * @property {number} [usage=0] - Number of uses of the file - * @property {string} [context] - Context of the file origin - * @property {boolean} [embedded=false] - Whether or not the file is embedded in vector db - * @property {string} [model] - The model to identify the group region of the file (for Azure OpenAI hosting) - * @property {string} [source] - The source of the file (e.g., from FileSources) - * @property {number} [width] - Optional width of the file - * @property {number} [height] - Optional height of the file - * @property {Object} [metadata] - Metadata related to the file - * @property {string} [metadata.fileIdentifier] - Unique identifier for the file in metadata - * @property {Date} [expiresAt] - Optional expiration date of the file - * @property {Date} [createdAt] - Date when the file was created - * @property {Date} [updatedAt] - Date when the file was updated - */ - -/** @type {MongooseSchema} */ -const fileSchema = mongoose.Schema( - { - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - index: true, - required: true, - }, - conversationId: { - type: String, - ref: 'Conversation', - index: true, - }, - file_id: { - type: String, - // required: true, - index: true, - }, - temp_file_id: { - type: String, - // required: true, - }, - bytes: { - type: Number, - required: true, - }, - filename: { - type: String, - required: true, - }, - filepath: { - type: String, - required: true, - }, - object: { - type: String, - required: true, - default: 'file', - }, - embedded: { - type: Boolean, - }, - type: { - type: String, - required: true, - }, - context: { - type: String, - // required: true, - }, - usage: { - type: Number, - required: true, - default: 0, - }, - source: { - type: String, - default: FileSources.local, - }, - model: { - type: String, - }, - width: Number, - height: Number, - metadata: { - fileIdentifier: String, - }, - expiresAt: { - type: Date, - expires: 3600, // 1 hour in seconds - }, - }, - { - timestamps: true, - }, -); - -fileSchema.index({ createdAt: 1, updatedAt: 1 }); - -module.exports = fileSchema; diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index be711552955..cf97b84eeae 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -1,145 +1,6 @@ const mongoose = require('mongoose'); const mongoMeili = require('~/models/plugins/mongoMeili'); -const messageSchema = mongoose.Schema( - { - messageId: { - type: String, - unique: true, - required: true, - index: true, - meiliIndex: true, - }, - conversationId: { - type: String, - index: true, - required: true, - meiliIndex: true, - }, - user: { - type: String, - index: true, - required: true, - default: null, - }, - model: { - type: String, - default: null, - }, - endpoint: { - type: String, - }, - conversationSignature: { - type: String, - }, - clientId: { - type: String, - }, - invocationId: { - type: Number, - }, - parentMessageId: { - type: String, - }, - tokenCount: { - type: Number, - }, - summaryTokenCount: { - type: Number, - }, - sender: { - type: String, - meiliIndex: true, - }, - text: { - type: String, - meiliIndex: true, - }, - summary: { - type: String, - }, - isCreatedByUser: { - type: Boolean, - required: true, - default: false, - }, - unfinished: { - type: Boolean, - default: false, - }, - error: { - type: Boolean, - default: false, - }, - finish_reason: { - type: String, - }, - _meiliIndex: { - type: Boolean, - required: false, - select: false, - default: false, - }, - files: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, - plugin: { - type: { - latest: { - type: String, - required: false, - }, - inputs: { - type: [mongoose.Schema.Types.Mixed], - required: false, - default: undefined, - }, - outputs: { - type: String, - required: false, - }, - }, - default: undefined, - }, - plugins: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, - content: { - type: [{ type: mongoose.Schema.Types.Mixed }], - default: undefined, - meiliIndex: true, - }, - thread_id: { - type: String, - }, - /* frontend components */ - iconURL: { - type: String, - }, - attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined }, - /* - attachments: { - type: [ - { - file_id: String, - filename: String, - filepath: String, - expiresAt: Date, - width: Number, - height: Number, - type: String, - conversationId: String, - messageId: { - type: String, - required: true, - }, - toolCallId: String, - }, - ], - default: undefined, - }, - */ - expiredAt: { - type: Date, - }, - }, - { timestamps: true }, -); +const { messageSchema } = require('@librechat/data-schemas'); if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { messageSchema.plugin(mongoMeili, { @@ -149,11 +10,7 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) { primaryKey: 'messageId', }); } -messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); -messageSchema.index({ createdAt: 1 }); -messageSchema.index({ messageId: 1, user: 1 }, { unique: true }); -/** @type {mongoose.Model} */ const Message = mongoose.models.Message || mongoose.model('Message', messageSchema); module.exports = Message; diff --git a/api/models/schema/pluginAuthSchema.js b/api/models/schema/pluginAuthSchema.js index 4b4251dda37..2066eda4c4f 100644 --- a/api/models/schema/pluginAuthSchema.js +++ b/api/models/schema/pluginAuthSchema.js @@ -1,25 +1,5 @@ const mongoose = require('mongoose'); - -const pluginAuthSchema = mongoose.Schema( - { - authField: { - type: String, - required: true, - }, - value: { - type: String, - required: true, - }, - userId: { - type: String, - required: true, - }, - pluginKey: { - type: String, - }, - }, - { timestamps: true }, -); +const { pluginAuthSchema } = require('@librechat/data-schemas'); const PluginAuth = mongoose.models.Plugin || mongoose.model('PluginAuth', pluginAuthSchema); diff --git a/api/models/schema/presetSchema.js b/api/models/schema/presetSchema.js index e1c92ab9c07..6d03803acea 100644 --- a/api/models/schema/presetSchema.js +++ b/api/models/schema/presetSchema.js @@ -1,38 +1,5 @@ const mongoose = require('mongoose'); -const { conversationPreset } = require('./defaults'); -const presetSchema = mongoose.Schema( - { - presetId: { - type: String, - unique: true, - required: true, - index: true, - }, - title: { - type: String, - default: 'New Chat', - meiliIndex: true, - }, - user: { - type: String, - default: null, - }, - defaultPreset: { - type: Boolean, - }, - order: { - type: Number, - }, - // google only - examples: [{ type: mongoose.Schema.Types.Mixed }], - ...conversationPreset, - agentOptions: { - type: mongoose.Schema.Types.Mixed, - default: null, - }, - }, - { timestamps: true }, -); +const { presetSchema } = require('@librechat/data-schemas'); const Preset = mongoose.models.Preset || mongoose.model('Preset', presetSchema); diff --git a/api/models/schema/projectSchema.js b/api/models/schema/projectSchema.js deleted file mode 100644 index dfa68a06c22..00000000000 --- a/api/models/schema/projectSchema.js +++ /dev/null @@ -1,35 +0,0 @@ -const { Schema } = require('mongoose'); - -/** - * @typedef {Object} MongoProject - * @property {ObjectId} [_id] - MongoDB Document ID - * @property {string} name - The name of the project - * @property {ObjectId[]} promptGroupIds - Array of PromptGroup IDs associated with the project - * @property {Date} [createdAt] - Date when the project was created (added by timestamps) - * @property {Date} [updatedAt] - Date when the project was last updated (added by timestamps) - */ - -const projectSchema = new Schema( - { - name: { - type: String, - required: true, - index: true, - }, - promptGroupIds: { - type: [Schema.Types.ObjectId], - ref: 'PromptGroup', - default: [], - }, - agentIds: { - type: [String], - ref: 'Agent', - default: [], - }, - }, - { - timestamps: true, - }, -); - -module.exports = projectSchema; diff --git a/api/models/schema/promptSchema.js b/api/models/schema/promptSchema.js deleted file mode 100644 index 5464caf639c..00000000000 --- a/api/models/schema/promptSchema.js +++ /dev/null @@ -1,118 +0,0 @@ -const mongoose = require('mongoose'); -const { Constants } = require('librechat-data-provider'); -const Schema = mongoose.Schema; - -/** - * @typedef {Object} MongoPromptGroup - * @property {ObjectId} [_id] - MongoDB Document ID - * @property {string} name - The name of the prompt group - * @property {ObjectId} author - The author of the prompt group - * @property {ObjectId} [projectId=null] - The project ID of the prompt group - * @property {ObjectId} [productionId=null] - The project ID of the prompt group - * @property {string} authorName - The name of the author of the prompt group - * @property {number} [numberOfGenerations=0] - Number of generations the prompt group has - * @property {string} [oneliner=''] - Oneliner description of the prompt group - * @property {string} [category=''] - Category of the prompt group - * @property {string} [command] - Command for the prompt group - * @property {Date} [createdAt] - Date when the prompt group was created (added by timestamps) - * @property {Date} [updatedAt] - Date when the prompt group was last updated (added by timestamps) - */ - -const promptGroupSchema = new Schema( - { - name: { - type: String, - required: true, - index: true, - }, - numberOfGenerations: { - type: Number, - default: 0, - }, - oneliner: { - type: String, - default: '', - }, - category: { - type: String, - default: '', - index: true, - }, - projectIds: { - type: [Schema.Types.ObjectId], - ref: 'Project', - index: true, - }, - productionId: { - type: Schema.Types.ObjectId, - ref: 'Prompt', - required: true, - index: true, - }, - author: { - type: Schema.Types.ObjectId, - ref: 'User', - required: true, - index: true, - }, - authorName: { - type: String, - required: true, - }, - command: { - type: String, - index: true, - validate: { - validator: function (v) { - return v === undefined || v === null || v === '' || /^[a-z0-9-]+$/.test(v); - }, - message: (props) => - `${props.value} is not a valid command. Only lowercase alphanumeric characters and highfins (') are allowed.`, - }, - maxlength: [ - Constants.COMMANDS_MAX_LENGTH, - `Command cannot be longer than ${Constants.COMMANDS_MAX_LENGTH} characters`, - ], - }, - }, - { - timestamps: true, - }, -); - -const PromptGroup = mongoose.model('PromptGroup', promptGroupSchema); - -const promptSchema = new Schema( - { - groupId: { - type: Schema.Types.ObjectId, - ref: 'PromptGroup', - required: true, - index: true, - }, - author: { - type: Schema.Types.ObjectId, - ref: 'User', - required: true, - }, - prompt: { - type: String, - required: true, - }, - type: { - type: String, - enum: ['text', 'chat'], - required: true, - }, - }, - { - timestamps: true, - }, -); - -const Prompt = mongoose.model('Prompt', promptSchema); - -promptSchema.index({ createdAt: 1, updatedAt: 1 }); -promptGroupSchema.index({ createdAt: 1, updatedAt: 1 }); - -module.exports = { Prompt, PromptGroup }; diff --git a/api/models/schema/session.js b/api/models/schema/session.js deleted file mode 100644 index ccda43573d3..00000000000 --- a/api/models/schema/session.js +++ /dev/null @@ -1,20 +0,0 @@ -const mongoose = require('mongoose'); - -const sessionSchema = mongoose.Schema({ - refreshTokenHash: { - type: String, - required: true, - }, - expiration: { - type: Date, - required: true, - expires: 0, - }, - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true, - }, -}); - -module.exports = sessionSchema; diff --git a/api/models/schema/toolCallSchema.js b/api/models/schema/toolCallSchema.js deleted file mode 100644 index 2af4c67c1be..00000000000 --- a/api/models/schema/toolCallSchema.js +++ /dev/null @@ -1,54 +0,0 @@ -const mongoose = require('mongoose'); - -/** - * @typedef {Object} ToolCallData - * @property {string} conversationId - The ID of the conversation - * @property {string} messageId - The ID of the message - * @property {string} toolId - The ID of the tool - * @property {string | ObjectId} user - The user's ObjectId - * @property {unknown} [result] - Optional result data - * @property {TAttachment[]} [attachments] - Optional attachments data - * @property {number} [blockIndex] - Optional code block index - * @property {number} [partIndex] - Optional part index - */ - -/** @type {MongooseSchema} */ -const toolCallSchema = mongoose.Schema( - { - conversationId: { - type: String, - required: true, - }, - messageId: { - type: String, - required: true, - }, - toolId: { - type: String, - required: true, - }, - user: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true, - }, - result: { - type: mongoose.Schema.Types.Mixed, - }, - attachments: { - type: mongoose.Schema.Types.Mixed, - }, - blockIndex: { - type: Number, - }, - partIndex: { - type: Number, - }, - }, - { timestamps: true }, -); - -toolCallSchema.index({ messageId: 1, user: 1 }); -toolCallSchema.index({ conversationId: 1, user: 1 }); - -module.exports = mongoose.model('ToolCall', toolCallSchema); diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js deleted file mode 100644 index bebc7fea1e5..00000000000 --- a/api/models/schema/userSchema.js +++ /dev/null @@ -1,151 +0,0 @@ -const mongoose = require('mongoose'); -const { SystemRoles } = require('librechat-data-provider'); - -/** - * @typedef {Object} MongoSession - * @property {string} [refreshToken] - The refresh token - */ - -/** - * @typedef {Object} MongoUser - * @property {ObjectId} [_id] - MongoDB Document ID - * @property {string} [name] - The user's name - * @property {string} [username] - The user's username, in lowercase - * @property {string} email - The user's email address - * @property {boolean} emailVerified - Whether the user's email is verified - * @property {string} [password] - The user's password, trimmed with 8-128 characters - * @property {string} [avatar] - The URL of the user's avatar - * @property {string} provider - The provider of the user's account (e.g., 'local', 'google') - * @property {string} [role='USER'] - The role of the user - * @property {string} [googleId] - Optional Google ID for the user - * @property {string} [facebookId] - Optional Facebook ID for the user - * @property {string} [openidId] - Optional OpenID ID for the user - * @property {string} [ldapId] - Optional LDAP ID for the user - * @property {string} [githubId] - Optional GitHub ID for the user - * @property {string} [discordId] - Optional Discord ID for the user - * @property {string} [appleId] - Optional Apple ID for the user - * @property {Array} [plugins=[]] - List of plugins used by the user - * @property {Array.} [refreshToken] - List of sessions with refresh tokens - * @property {Date} [expiresAt] - Optional expiration date of the file - * @property {Date} [createdAt] - Date when the user was created (added by timestamps) - * @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps) - */ - -/** @type {MongooseSchema} */ -const Session = mongoose.Schema({ - refreshToken: { - type: String, - default: '', - }, -}); - -const backupCodeSchema = mongoose.Schema({ - codeHash: { type: String, required: true }, - used: { type: Boolean, default: false }, - usedAt: { type: Date, default: null }, -}); - -/** @type {MongooseSchema} */ -const userSchema = mongoose.Schema( - { - name: { - type: String, - }, - username: { - type: String, - lowercase: true, - default: '', - }, - email: { - type: String, - required: [true, 'can\'t be blank'], - lowercase: true, - unique: true, - match: [/\S+@\S+\.\S+/, 'is invalid'], - index: true, - }, - emailVerified: { - type: Boolean, - required: true, - default: false, - }, - password: { - type: String, - trim: true, - minlength: 8, - maxlength: 128, - }, - avatar: { - type: String, - required: false, - }, - provider: { - type: String, - required: true, - default: 'local', - }, - role: { - type: String, - default: SystemRoles.USER, - }, - googleId: { - type: String, - unique: true, - sparse: true, - }, - facebookId: { - type: String, - unique: true, - sparse: true, - }, - openidId: { - type: String, - unique: true, - sparse: true, - }, - ldapId: { - type: String, - unique: true, - sparse: true, - }, - githubId: { - type: String, - unique: true, - sparse: true, - }, - discordId: { - type: String, - unique: true, - sparse: true, - }, - appleId: { - type: String, - unique: true, - sparse: true, - }, - plugins: { - type: Array, - }, - totpSecret: { - type: String, - }, - backupCodes: { - type: [backupCodeSchema], - }, - refreshToken: { - type: [Session], - }, - expiresAt: { - type: Date, - expires: 604800, // 7 days in seconds - }, - termsAccepted: { - type: Boolean, - default: false, - }, - }, - - { timestamps: true }, -); - -module.exports = userSchema; diff --git a/api/models/tx.js b/api/models/tx.js index 82ae9fb0347..b534e7edc93 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -79,6 +79,7 @@ const tokenValues = Object.assign( 'o1-mini': { prompt: 1.1, completion: 4.4 }, 'o1-preview': { prompt: 15, completion: 60 }, o1: { prompt: 15, completion: 60 }, + 'gpt-4.5': { prompt: 75, completion: 150 }, 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, 'gpt-4o': { prompt: 2.5, completion: 10 }, 'gpt-4o-2024-05-13': { prompt: 5, completion: 15 }, @@ -167,6 +168,8 @@ const getValueKey = (model, endpoint) => { 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-4o-2024-05-13')) { return 'gpt-4o-2024-05-13'; } else if (modelName.includes('gpt-4o-mini')) { diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 9cec82165f5..b04eacc9f30 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -50,6 +50,16 @@ describe('getValueKey', () => { expect(getValueKey('gpt-4-0125')).toBe('gpt-4-1106'); }); + it('should return "gpt-4.5" for model type of "gpt-4.5"', () => { + expect(getValueKey('gpt-4.5-preview')).toBe('gpt-4.5'); + expect(getValueKey('gpt-4.5-2024-08-06')).toBe('gpt-4.5'); + expect(getValueKey('gpt-4.5-2024-08-06-0718')).toBe('gpt-4.5'); + expect(getValueKey('openai/gpt-4.5')).toBe('gpt-4.5'); + expect(getValueKey('openai/gpt-4.5-2024-08-06')).toBe('gpt-4.5'); + expect(getValueKey('gpt-4.5-turbo')).toBe('gpt-4.5'); + expect(getValueKey('gpt-4.5-0125')).toBe('gpt-4.5'); + }); + 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'); diff --git a/api/package.json b/api/package.json index e386394acb0..cfc9977aaf8 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.7.7-rc1", + "version": "v0.7.7", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -36,18 +36,19 @@ "dependencies": { "@anthropic-ai/sdk": "^0.37.0", "@azure/search-documents": "^12.0.0", - "@google/generative-ai": "^0.21.0", + "@google/generative-ai": "^0.23.0", "@googleapis/youtube": "^20.0.0", "@keyv/mongo": "^2.1.8", "@keyv/redis": "^2.8.1", - "@langchain/community": "^0.3.14", + "@langchain/community": "^0.3.34", "@langchain/core": "^0.3.40", "@langchain/google-genai": "^0.1.9", "@langchain/google-vertexai": "^0.2.0", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.1.3", + "@librechat/agents": "^2.2.0", + "@librechat/data-schemas": "*", "@waylaidwanderer/fetch-event-source": "^3.0.1", - "axios": "1.7.8", + "axios": "^1.8.2", "bcryptjs": "^2.4.3", "cohere-ai": "^7.9.1", "compression": "^1.7.4", @@ -74,7 +75,6 @@ "keyv": "^4.5.4", "keyv-file": "^0.2.0", "klona": "^2.0.6", - "langchain": "^0.2.19", "librechat-data-provider": "*", "librechat-mcp": "*", "lodash": "^4.17.21", diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 79dc81d6b02..ad120c2c837 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,6 +1,7 @@ const { CacheKeys } = require('librechat-data-provider'); const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); +const { logger } = require('~/config'); /** * @param {ServerRequest} req @@ -36,8 +37,13 @@ async function loadModels(req) { } async function modelController(req, res) { - const modelConfig = await loadModels(req); - res.send(modelConfig); + try { + const modelConfig = await loadModels(req); + res.send(modelConfig); + } catch (error) { + logger.error('Error fetching models:', error); + res.status(500).send({ error: error.message }); + } } module.exports = { modelController, loadModels, getModelsConfig }; diff --git a/api/server/controllers/TwoFactorController.js b/api/server/controllers/TwoFactorController.js index f145d69d922..36bc603ae54 100644 --- a/api/server/controllers/TwoFactorController.js +++ b/api/server/controllers/TwoFactorController.js @@ -11,17 +11,19 @@ const { encryptV2 } = require('~/server/utils/crypto'); const enable2FAController = async (req, res) => { const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, ''); - try { const userId = req.user.id; const secret = generateTOTPSecret(); const { plainCodes, codeObjects } = await generateBackupCodes(); - const encryptedSecret = await encryptV2(secret); - const user = await updateUser(userId, { totpSecret: encryptedSecret, backupCodes: codeObjects }); + // Set twoFactorEnabled to false until the user confirms 2FA. + const user = await updateUser(userId, { + totpSecret: encryptedSecret, + backupCodes: codeObjects, + twoFactorEnabled: false, + }); const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`; - res.status(200).json({ otpauthUrl, backupCodes: plainCodes, @@ -37,6 +39,7 @@ const verify2FAController = async (req, res) => { const userId = req.user.id; const { token, backupCode } = req.body; const user = await getUserById(userId); + // Ensure that 2FA is enabled for this user. if (!user || !user.totpSecret) { return res.status(400).json({ message: '2FA not initiated' }); } @@ -52,7 +55,6 @@ const verify2FAController = async (req, res) => { return res.status(200).json(); } } - return res.status(400).json({ message: 'Invalid token.' }); } catch (err) { logger.error('[verify2FAController]', err); @@ -74,6 +76,8 @@ const confirm2FAController = async (req, res) => { const secret = await getTOTPSecret(user.totpSecret); if (await verifyTOTP(secret, token)) { + // Upon successful verification, enable 2FA. + await updateUser(userId, { twoFactorEnabled: true }); return res.status(200).json(); } @@ -87,7 +91,7 @@ const confirm2FAController = async (req, res) => { const disable2FAController = async (req, res) => { try { const userId = req.user.id; - await updateUser(userId, { totpSecret: null, backupCodes: [] }); + await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false }); res.status(200).json(); } catch (err) { logger.error('[disable2FAController]', err); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index f43c9db5bae..45beefe7e67 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -1,4 +1,5 @@ -const { Tools, StepTypes, imageGenTools, FileContext } = require('librechat-data-provider'); +const { nanoid } = require('nanoid'); +const { Tools, StepTypes, FileContext } = require('librechat-data-provider'); const { EnvVar, Providers, @@ -242,32 +243,6 @@ function createToolEndCallback({ req, res, artifactPromises }) { return; } - if (imageGenTools.has(output.name)) { - artifactPromises.push( - (async () => { - const fileMetadata = Object.assign(output.artifact, { - messageId: metadata.run_id, - toolCallId: output.tool_call_id, - conversationId: metadata.thread_id, - }); - if (!res.headersSent) { - return fileMetadata; - } - - if (!fileMetadata) { - return null; - } - - res.write(`event: attachment\ndata: ${JSON.stringify(fileMetadata)}\n\n`); - return fileMetadata; - })().catch((error) => { - logger.error('Error processing code output:', error); - return null; - }), - ); - return; - } - if (output.artifact.content) { /** @type {FormattedContent[]} */ const content = output.artifact.content; @@ -278,7 +253,7 @@ function createToolEndCallback({ req, res, artifactPromises }) { const { url } = part.image_url; artifactPromises.push( (async () => { - const filename = `${output.tool_call_id}-image-${new Date().getTime()}`; + const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`; const file = await saveBase64Image(url, { req, filename, diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index bff7dc65eb1..628b62e5ea7 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -17,7 +17,7 @@ const { KnownEndpoints, anthropicSchema, isAgentsEndpoint, - bedrockOutputParser, + bedrockInputSchema, removeNullishValues, } = require('librechat-data-provider'); const { @@ -27,10 +27,11 @@ const { formatContentStrings, createContextHandlers, } = require('~/app/clients/prompts'); -const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { getBufferString, HumanMessage } = require('@langchain/core/messages'); +const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { getCustomEndpointConfig } = require('~/server/services/Config'); const Tokenizer = require('~/server/services/Tokenizer'); -const { spendTokens } = require('~/models/spendTokens'); const BaseClient = require('~/app/clients/BaseClient'); const { createRun } = require('./run'); const { logger } = require('~/config'); @@ -39,10 +40,10 @@ const { logger } = require('~/config'); /** @typedef {import('@langchain/core/runnables').RunnableConfig} RunnableConfig */ const providerParsers = { - [EModelEndpoint.openAI]: openAISchema, - [EModelEndpoint.azureOpenAI]: openAISchema, - [EModelEndpoint.anthropic]: anthropicSchema, - [EModelEndpoint.bedrock]: bedrockOutputParser, + [EModelEndpoint.openAI]: openAISchema.parse, + [EModelEndpoint.azureOpenAI]: openAISchema.parse, + [EModelEndpoint.anthropic]: anthropicSchema.parse, + [EModelEndpoint.bedrock]: bedrockInputSchema.parse, }; const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]); @@ -187,7 +188,14 @@ class AgentClient extends BaseClient { : {}; if (parseOptions) { - runOptions = parseOptions(this.options.agent.model_parameters); + try { + runOptions = parseOptions(this.options.agent.model_parameters); + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #getSaveOptions] Error parsing options', + error, + ); + } } return removeNullishValues( @@ -380,15 +388,34 @@ class AgentClient extends BaseClient { if (!collectedUsage || !collectedUsage.length) { return; } - const input_tokens = collectedUsage[0]?.input_tokens || 0; + const input_tokens = + (collectedUsage[0]?.input_tokens || 0) + + (Number(collectedUsage[0]?.input_token_details?.cache_creation) || 0) + + (Number(collectedUsage[0]?.input_token_details?.cache_read) || 0); let output_tokens = 0; let previousTokens = input_tokens; // Start with original input for (let i = 0; i < collectedUsage.length; i++) { const usage = collectedUsage[i]; + if (!usage) { + continue; + } + + const cache_creation = Number(usage.input_token_details?.cache_creation) || 0; + const cache_read = Number(usage.input_token_details?.cache_read) || 0; + + const txMetadata = { + context, + conversationId: this.conversationId, + user: this.user ?? this.options.req.user?.id, + endpointTokenConfig: this.options.endpointTokenConfig, + model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model, + }; + if (i > 0) { // Count new tokens generated (input_tokens minus previous accumulated tokens) - output_tokens += (Number(usage.input_tokens) || 0) - previousTokens; + output_tokens += + (Number(usage.input_tokens) || 0) + cache_creation + cache_read - previousTokens; } // Add this message's output tokens @@ -396,16 +423,26 @@ class AgentClient extends BaseClient { // Update previousTokens to include this message's output previousTokens += Number(usage.output_tokens) || 0; - spendTokens( - { - context, - conversationId: this.conversationId, - user: this.user ?? this.options.req.user?.id, - endpointTokenConfig: this.options.endpointTokenConfig, - model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model, - }, - { promptTokens: usage.input_tokens, completionTokens: usage.output_tokens }, - ).catch((err) => { + + if (cache_creation > 0 || cache_read > 0) { + spendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }).catch((err) => { + logger.error( + '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending structured tokens', + err, + ); + }); + } + spendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }).catch((err) => { logger.error( '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending tokens', err, @@ -766,18 +803,20 @@ class AgentClient extends BaseClient { ); } } catch (err) { + logger.error( + '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', + err, + ); if (!abortController.signal.aborted) { logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type', err, ); - throw err; + this.contentParts.push({ + type: ContentTypes.ERROR, + [ContentTypes.ERROR]: `An error occurred while processing the request${err?.message ? `: ${err.message}` : ''}`, + }); } - - logger.warn( - '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', - err, - ); } } @@ -792,14 +831,20 @@ class AgentClient extends BaseClient { throw new Error('Run not initialized'); } const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator(); - const clientOptions = {}; - const providerConfig = this.options.req.app.locals[this.options.agent.provider]; + /** @type {import('@librechat/agents').ClientOptions} */ + const clientOptions = { + maxTokens: 75, + }; + let endpointConfig = this.options.req.app.locals[this.options.agent.endpoint]; + if (!endpointConfig) { + endpointConfig = await getCustomEndpointConfig(this.options.agent.endpoint); + } if ( - providerConfig && - providerConfig.titleModel && - providerConfig.titleModel !== Constants.CURRENT_MODEL + endpointConfig && + endpointConfig.titleModel && + endpointConfig.titleModel !== Constants.CURRENT_MODEL ) { - clientOptions.model = providerConfig.titleModel; + clientOptions.model = endpointConfig.titleModel; } try { const titleResult = await this.run.generateTitle({ diff --git a/api/server/controllers/agents/run.js b/api/server/controllers/agents/run.js index 6c98a641dbc..59d1a5f1461 100644 --- a/api/server/controllers/agents/run.js +++ b/api/server/controllers/agents/run.js @@ -45,7 +45,10 @@ async function createRun({ /** @type {'reasoning_content' | 'reasoning'} */ let reasoningKey; - if (llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter)) { + if ( + llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter) || + (agent.endpoint && agent.endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) + ) { reasoningKey = 'reasoning'; } if (/o1(?!-(?:mini|preview)).*$/.test(llmConfig.model)) { diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 8ab9a99ddbf..226b5605cc6 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -8,7 +8,7 @@ const loginController = async (req, res) => { return res.status(400).json({ message: 'Invalid credentials' }); } - if (req.user.backupCodes != null && req.user.backupCodes.length > 0) { + if (req.user.twoFactorEnabled) { const tempToken = generate2FATempToken(req.user._id); return res.status(200).json({ twoFAPending: true, tempToken }); } diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js index 78c5c0314ed..16907833687 100644 --- a/api/server/controllers/auth/TwoFactorAuthController.js +++ b/api/server/controllers/auth/TwoFactorAuthController.js @@ -1,5 +1,9 @@ const jwt = require('jsonwebtoken'); -const { verifyTOTP, verifyBackupCode, getTOTPSecret } = require('~/server/services/twoFactorService'); +const { + verifyTOTP, + verifyBackupCode, + getTOTPSecret, +} = require('~/server/services/twoFactorService'); const { setAuthTokens } = require('~/server/services/AuthService'); const { getUserById } = require('~/models/userMethods'); const { logger } = require('~/config'); @@ -19,12 +23,12 @@ const verify2FA = async (req, res) => { } const user = await getUserById(payload.userId); - // Ensure that the user exists and has backup codes (i.e. 2FA enabled) - if (!user || !(user.backupCodes && user.backupCodes.length > 0)) { + // Ensure that the user exists and has 2FA enabled + if (!user || !user.twoFactorEnabled) { return res.status(400).json({ message: '2FA is not enabled for this user' }); } - // Use the new getTOTPSecret function to retrieve (and decrypt if necessary) the TOTP secret. + // Retrieve (and decrypt if necessary) the TOTP secret. const secret = await getTOTPSecret(user.totpSecret); let verified = false; @@ -39,9 +43,7 @@ const verify2FA = async (req, res) => { } // Prepare user data for response. - // If the user is a plain object (from lean queries), we create a shallow copy. const userData = user.toObject ? user.toObject() : { ...user }; - // Remove sensitive fields. delete userData.password; delete userData.__v; delete userData.totpSecret; diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 2137523efe3..0053f2bde6b 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -120,7 +120,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => { { promptTokens, completionTokens }, ); - saveMessage( + await saveMessage( req, { ...responseMessage, user }, { context: 'api/server/middleware/abortMiddleware.js' }, diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js index 8319baf345c..a71bd6c5b01 100644 --- a/api/server/middleware/requireLocalAuth.js +++ b/api/server/middleware/requireLocalAuth.js @@ -1,32 +1,18 @@ const passport = require('passport'); -const DebugControl = require('../../utils/debug.js'); - -function log({ title, parameters }) { - DebugControl.log.functionName(title); - if (parameters) { - DebugControl.log.parameters(parameters); - } -} +const { logger } = require('~/config'); const requireLocalAuth = (req, res, next) => { passport.authenticate('local', (err, user, info) => { if (err) { - log({ - title: '(requireLocalAuth) Error at passport.authenticate', - parameters: [{ name: 'error', value: err }], - }); + logger.error('[requireLocalAuth] Error at passport.authenticate:', err); return next(err); } if (!user) { - log({ - title: '(requireLocalAuth) Error: No user', - }); + logger.debug('[requireLocalAuth] Error: No user'); return res.status(404).send(info); } if (info && info.message) { - log({ - title: '(requireLocalAuth) Error: ' + info.message, - }); + logger.debug('[requireLocalAuth] Error: ' + info.message); return res.status(422).send({ message: info.message }); } req.user = user; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 115acdce4dd..e8d2fe57ac5 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -47,10 +47,10 @@ router.get('/', async function (req, res) { githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET, googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET, appleLoginEnabled: - !!process.env.APPLE_CLIENT_ID && - !!process.env.APPLE_TEAM_ID && - !!process.env.APPLE_KEY_ID && - !!process.env.APPLE_PRIVATE_KEY_PATH, + !!process.env.APPLE_CLIENT_ID && + !!process.env.APPLE_TEAM_ID && + !!process.env.APPLE_KEY_ID && + !!process.env.APPLE_PRIVATE_KEY_PATH, openidLoginEnabled: !!process.env.OPENID_CLIENT_ID && !!process.env.OPENID_CLIENT_SECRET && @@ -81,6 +81,7 @@ router.get('/', async function (req, res) { publicSharedLinksEnabled, analyticsGtmId: process.env.ANALYTICS_GTM_ID, instanceProjectId: instanceProject._id.toString(), + bundlerURL: process.env.SANDPACK_BUNDLER_URL, }; if (ldap) { diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 0df811468b8..fc255b8c47a 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -47,7 +47,7 @@ async function loadConfigModels(req) { ); /** - * @type {Record} + * @type {Record>} * Map for promises keyed by unique combination of baseURL and apiKey */ const fetchPromisesMap = {}; /** @@ -102,7 +102,7 @@ async function loadConfigModels(req) { for (const name of associatedNames) { const endpoint = endpointsMap[name]; - modelsConfig[name] = !modelData?.length ? endpoint.models.default ?? [] : modelData; + modelsConfig[name] = !modelData?.length ? (endpoint.models.default ?? []) : modelData; } } diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index 82db356841f..db91c4101be 100644 --- a/api/server/services/Config/loadDefaultModels.js +++ b/api/server/services/Config/loadDefaultModels.js @@ -5,8 +5,8 @@ const { getGoogleModels, getBedrockModels, getAnthropicModels, - getChatGPTBrowserModels, } = require('~/server/services/ModelService'); +const { logger } = require('~/config'); /** * Loads the default models for the application. @@ -15,31 +15,68 @@ const { * @param {Express.Request} req - The Express request object. */ async function loadDefaultModels(req) { - const google = getGoogleModels(); - const openAI = await getOpenAIModels({ user: req.user.id }); - const anthropic = getAnthropicModels(); - const chatGPTBrowser = getChatGPTBrowserModels(); - const azureOpenAI = await getOpenAIModels({ user: req.user.id, azure: true }); - const gptPlugins = await getOpenAIModels({ - user: req.user.id, - azure: useAzurePlugins, - plugins: true, - }); - const assistants = await getOpenAIModels({ assistants: true }); - const azureAssistants = await getOpenAIModels({ azureAssistants: true }); + try { + const [ + openAI, + anthropic, + azureOpenAI, + gptPlugins, + assistants, + azureAssistants, + google, + bedrock, + ] = await Promise.all([ + getOpenAIModels({ user: req.user.id }).catch((error) => { + logger.error('Error fetching OpenAI models:', error); + return []; + }), + getAnthropicModels({ user: req.user.id }).catch((error) => { + logger.error('Error fetching Anthropic models:', error); + return []; + }), + getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => { + logger.error('Error fetching Azure OpenAI models:', error); + return []; + }), + getOpenAIModels({ user: req.user.id, azure: useAzurePlugins, plugins: true }).catch( + (error) => { + logger.error('Error fetching Plugin models:', error); + return []; + }, + ), + getOpenAIModels({ assistants: true }).catch((error) => { + logger.error('Error fetching OpenAI Assistants API models:', error); + return []; + }), + getOpenAIModels({ azureAssistants: true }).catch((error) => { + logger.error('Error fetching Azure OpenAI Assistants API models:', error); + return []; + }), + Promise.resolve(getGoogleModels()).catch((error) => { + logger.error('Error getting Google models:', error); + return []; + }), + Promise.resolve(getBedrockModels()).catch((error) => { + logger.error('Error getting Bedrock models:', error); + return []; + }), + ]); - return { - [EModelEndpoint.openAI]: openAI, - [EModelEndpoint.agents]: openAI, - [EModelEndpoint.google]: google, - [EModelEndpoint.anthropic]: anthropic, - [EModelEndpoint.gptPlugins]: gptPlugins, - [EModelEndpoint.azureOpenAI]: azureOpenAI, - [EModelEndpoint.chatGPTBrowser]: chatGPTBrowser, - [EModelEndpoint.assistants]: assistants, - [EModelEndpoint.azureAssistants]: azureAssistants, - [EModelEndpoint.bedrock]: getBedrockModels(), - }; + return { + [EModelEndpoint.openAI]: openAI, + [EModelEndpoint.agents]: openAI, + [EModelEndpoint.google]: google, + [EModelEndpoint.anthropic]: anthropic, + [EModelEndpoint.gptPlugins]: gptPlugins, + [EModelEndpoint.azureOpenAI]: azureOpenAI, + [EModelEndpoint.assistants]: assistants, + [EModelEndpoint.azureAssistants]: azureAssistants, + [EModelEndpoint.bedrock]: bedrock, + }; + } catch (error) { + logger.error('Error fetching default models:', error); + throw new Error(`Failed to load default models: ${error.message}`); + } } module.exports = loadDefaultModels; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 1726ef34601..1cf8ad7a671 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -22,6 +22,7 @@ const { getAgent } = require('~/models/Agent'); const { logger } = require('~/config'); const providerConfigMap = { + [Providers.XAI]: initCustom, [Providers.OLLAMA]: initCustom, [Providers.DEEPSEEK]: initCustom, [Providers.OPENROUTER]: initCustom, @@ -101,6 +102,7 @@ const initializeAgentOptions = async ({ }); const provider = agent.provider; + agent.endpoint = provider; let getOptions = providerConfigMap[provider]; if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { agent.provider = provider.toLowerCase(); @@ -112,9 +114,7 @@ const initializeAgentOptions = async ({ } getOptions = initCustom; agent.provider = Providers.OPENAI; - agent.endpoint = provider.toLowerCase(); } - const model_parameters = Object.assign( {}, agent.model_parameters ?? { model: agent.model }, diff --git a/api/server/services/Endpoints/agents/title.js b/api/server/services/Endpoints/agents/title.js index 56fd28668df..f25746582e7 100644 --- a/api/server/services/Endpoints/agents/title.js +++ b/api/server/services/Endpoints/agents/title.js @@ -20,10 +20,19 @@ const addTitle = async (req, { text, response, client }) => { const titleCache = getLogStores(CacheKeys.GEN_TITLE); const key = `${req.user.id}-${response.conversationId}`; + const responseText = + response?.content && Array.isArray(response?.content) + ? response.content.reduce((acc, block) => { + if (block?.type === 'text') { + return acc + block.text; + } + return acc; + }, '') + : (response?.content ?? response?.text ?? ''); const title = await client.titleConvo({ text, - responseText: response?.text ?? '', + responseText, conversationId: response.conversationId, }); await titleCache.set(key, title, 120000); diff --git a/api/server/services/Endpoints/anthropic/helpers.js b/api/server/services/Endpoints/anthropic/helpers.js index c7425f6ff1f..04e4efc61c4 100644 --- a/api/server/services/Endpoints/anthropic/helpers.js +++ b/api/server/services/Endpoints/anthropic/helpers.js @@ -48,7 +48,8 @@ function getClaudeHeaders(model, supportsCacheControl) { }; } else if (/claude-3[-.]7/.test(model)) { return { - 'anthropic-beta': 'output-128k-2025-02-19,prompt-caching-2024-07-31', + 'anthropic-beta': + 'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31', }; } else { return { diff --git a/api/server/services/Endpoints/anthropic/initialize.js b/api/server/services/Endpoints/anthropic/initialize.js index ffd61441beb..6c89eff4638 100644 --- a/api/server/services/Endpoints/anthropic/initialize.js +++ b/api/server/services/Endpoints/anthropic/initialize.js @@ -27,6 +27,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio if (anthropicConfig) { clientOptions.streamRate = anthropicConfig.streamRate; + clientOptions.titleModel = anthropicConfig.titleModel; } /** @type {undefined | TBaseEndpoint} */ diff --git a/api/server/services/Endpoints/anthropic/llm.js b/api/server/services/Endpoints/anthropic/llm.js index c68fd4738d8..9f20b8e61df 100644 --- a/api/server/services/Endpoints/anthropic/llm.js +++ b/api/server/services/Endpoints/anthropic/llm.js @@ -1,6 +1,6 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { anthropicSettings, removeNullishValues } = require('librechat-data-provider'); -const { checkPromptCacheSupport, getClaudeHeaders } = require('./helpers'); +const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers'); /** * Generates configuration options for creating an Anthropic language model (LLM) instance. @@ -43,14 +43,22 @@ function getLLMConfig(apiKey, options = {}) { model: mergedOptions.model, stream: mergedOptions.stream, temperature: mergedOptions.temperature, - topP: mergedOptions.topP, - topK: mergedOptions.topK, stopSequences: mergedOptions.stop, maxTokens: mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model), clientOptions: {}, }; + requestOptions = configureReasoning(requestOptions, systemOptions); + + if (!/claude-3[-.]7/.test(mergedOptions.model)) { + requestOptions.topP = mergedOptions.topP; + requestOptions.topK = mergedOptions.topK; + } else if (requestOptions.thinking == null) { + requestOptions.topP = mergedOptions.topP; + requestOptions.topK = mergedOptions.topK; + } + const supportsCacheControl = systemOptions.promptCache === true && checkPromptCacheSupport(requestOptions.model); const headers = getClaudeHeaders(requestOptions.model, supportsCacheControl); diff --git a/api/server/services/Endpoints/anthropic/llm.spec.js b/api/server/services/Endpoints/anthropic/llm.spec.js new file mode 100644 index 00000000000..9c453efb924 --- /dev/null +++ b/api/server/services/Endpoints/anthropic/llm.spec.js @@ -0,0 +1,153 @@ +const { anthropicSettings } = require('librechat-data-provider'); +const { getLLMConfig } = require('~/server/services/Endpoints/anthropic/llm'); + +jest.mock('https-proxy-agent', () => ({ + HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })), +})); + +describe('getLLMConfig', () => { + it('should create a basic configuration with default values', () => { + const result = getLLMConfig('test-api-key', { modelOptions: {} }); + + expect(result.llmConfig).toHaveProperty('apiKey', 'test-api-key'); + expect(result.llmConfig).toHaveProperty('model', anthropicSettings.model.default); + expect(result.llmConfig).toHaveProperty('stream', true); + expect(result.llmConfig).toHaveProperty('maxTokens'); + }); + + it('should include proxy settings when provided', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: {}, + proxy: 'http://proxy:8080', + }); + + expect(result.llmConfig.clientOptions).toHaveProperty('httpAgent'); + expect(result.llmConfig.clientOptions.httpAgent).toHaveProperty('proxy', 'http://proxy:8080'); + }); + + it('should include reverse proxy URL when provided', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: {}, + reverseProxyUrl: 'http://reverse-proxy', + }); + + expect(result.llmConfig.clientOptions).toHaveProperty('baseURL', 'http://reverse-proxy'); + }); + + it('should include topK and topP for non-Claude-3.7 models', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-opus', + topK: 10, + topP: 0.9, + }, + }); + + expect(result.llmConfig).toHaveProperty('topK', 10); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + }); + + it('should include topK and topP for Claude-3.5 models', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-5-sonnet', + topK: 10, + topP: 0.9, + }, + }); + + expect(result.llmConfig).toHaveProperty('topK', 10); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + }); + + it('should NOT include topK and topP for Claude-3-7 models (hyphen notation)', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-7-sonnet', + topK: 10, + topP: 0.9, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig).not.toHaveProperty('topP'); + }); + + it('should NOT include topK and topP for Claude-3.7 models (decimal notation)', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3.7-sonnet', + topK: 10, + topP: 0.9, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig).not.toHaveProperty('topP'); + }); + + it('should handle custom maxOutputTokens', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-opus', + maxOutputTokens: 2048, + }, + }); + + expect(result.llmConfig).toHaveProperty('maxTokens', 2048); + }); + + it('should handle promptCache setting', () => { + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-5-sonnet', + promptCache: true, + }, + }); + + // We're not checking specific header values since that depends on the actual helper function + // Just verifying that the promptCache setting is processed + expect(result.llmConfig).toBeDefined(); + }); + + it('should include topK and topP for Claude-3.7 models when thinking is not enabled', () => { + // Test with thinking explicitly set to null/undefined + const result = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-7-sonnet', + topK: 10, + topP: 0.9, + thinking: false, + }, + }); + + expect(result.llmConfig).toHaveProperty('topK', 10); + expect(result.llmConfig).toHaveProperty('topP', 0.9); + + // Test with thinking explicitly set to false + const result2 = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3-7-sonnet', + topK: 10, + topP: 0.9, + thinking: false, + }, + }); + + expect(result2.llmConfig).toHaveProperty('topK', 10); + expect(result2.llmConfig).toHaveProperty('topP', 0.9); + + // Test with decimal notation as well + const result3 = getLLMConfig('test-api-key', { + modelOptions: { + model: 'claude-3.7-sonnet', + topK: 10, + topP: 0.9, + thinking: false, + }, + }); + + expect(result3.llmConfig).toHaveProperty('topK', 10); + expect(result3.llmConfig).toHaveProperty('topP', 0.9); + }); +}); diff --git a/api/server/services/Endpoints/bedrock/build.js b/api/server/services/Endpoints/bedrock/build.js index d6fb0636a93..f5228160fcc 100644 --- a/api/server/services/Endpoints/bedrock/build.js +++ b/api/server/services/Endpoints/bedrock/build.js @@ -1,6 +1,5 @@ -const { removeNullishValues, bedrockInputParser } = require('librechat-data-provider'); +const { removeNullishValues } = require('librechat-data-provider'); const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); -const { logger } = require('~/config'); const buildOptions = (endpoint, parsedBody) => { const { @@ -15,12 +14,6 @@ const buildOptions = (endpoint, parsedBody) => { artifacts, ...model_parameters } = parsedBody; - let parsedParams = model_parameters; - try { - parsedParams = bedrockInputParser.parse(model_parameters); - } catch (error) { - logger.warn('Failed to parse bedrock input', error); - } const endpointOption = removeNullishValues({ endpoint, name, @@ -31,7 +24,7 @@ const buildOptions = (endpoint, parsedBody) => { spec, promptPrefix, maxContextTokens, - model_parameters: parsedParams, + model_parameters, }); if (typeof artifacts === 'string') { diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index 11b33a5357e..6740ae882e7 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,14 +1,16 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { - EModelEndpoint, - Constants, AuthType, + Constants, + EModelEndpoint, + bedrockInputParser, + bedrockOutputParser, removeNullishValues, } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { sleep } = require('~/server/utils'); -const getOptions = async ({ req, endpointOption }) => { +const getOptions = async ({ req, overrideModel, endpointOption }) => { const { BEDROCK_AWS_SECRET_ACCESS_KEY, BEDROCK_AWS_ACCESS_KEY_ID, @@ -62,39 +64,44 @@ const getOptions = async ({ req, endpointOption }) => { /** @type {BedrockClientOptions} */ const requestOptions = { - model: endpointOption.model, + model: overrideModel ?? endpointOption.model, region: BEDROCK_AWS_DEFAULT_REGION, - streaming: true, - streamUsage: true, - callbacks: [ - { - handleLLMNewToken: async () => { - if (!streamRate) { - return; - } - await sleep(streamRate); - }, - }, - ], }; + const configOptions = {}; + if (PROXY) { + /** NOTE: NOT SUPPORTED BY BEDROCK */ + configOptions.httpAgent = new HttpsProxyAgent(PROXY); + } + + const llmConfig = bedrockOutputParser( + bedrockInputParser.parse( + removeNullishValues(Object.assign(requestOptions, endpointOption.model_parameters)), + ), + ); + if (credentials) { - requestOptions.credentials = credentials; + llmConfig.credentials = credentials; } if (BEDROCK_REVERSE_PROXY) { - requestOptions.endpointHost = BEDROCK_REVERSE_PROXY; + llmConfig.endpointHost = BEDROCK_REVERSE_PROXY; } - const configOptions = {}; - if (PROXY) { - /** NOTE: NOT SUPPORTED BY BEDROCK */ - configOptions.httpAgent = new HttpsProxyAgent(PROXY); - } + llmConfig.callbacks = [ + { + handleLLMNewToken: async () => { + if (!streamRate) { + return; + } + await sleep(streamRate); + }, + }, + ]; return { /** @type {BedrockClientOptions} */ - llmConfig: removeNullishValues(Object.assign(requestOptions, endpointOption.model_parameters)), + llmConfig, configOptions, }; }; diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index fe2beba582b..e98ec71980f 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -141,7 +141,8 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid }, clientOptions, ); - const options = getLLMConfig(apiKey, clientOptions); + clientOptions.modelOptions.user = req.user.id; + const options = getLLMConfig(apiKey, clientOptions, endpoint); if (!customOptions.streamRate) { return options; } diff --git a/api/server/services/Endpoints/google/initialize.js b/api/server/services/Endpoints/google/initialize.js index c157dd8b28e..b7419a8a874 100644 --- a/api/server/services/Endpoints/google/initialize.js +++ b/api/server/services/Endpoints/google/initialize.js @@ -5,12 +5,7 @@ const { isEnabled } = require('~/server/utils'); const { GoogleClient } = require('~/app'); const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => { - const { - GOOGLE_KEY, - GOOGLE_REVERSE_PROXY, - GOOGLE_AUTH_HEADER, - PROXY, - } = process.env; + const { GOOGLE_KEY, GOOGLE_REVERSE_PROXY, GOOGLE_AUTH_HEADER, PROXY } = process.env; const isUserProvided = GOOGLE_KEY === 'user_provided'; const { key: expiresAt } = req.body; @@ -43,6 +38,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio if (googleConfig) { clientOptions.streamRate = googleConfig.streamRate; + clientOptions.titleModel = googleConfig.titleModel; } if (allConfig) { diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index 0eb0d566b94..5614804b68e 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -113,6 +113,7 @@ const initializeClient = async ({ if (!isAzureOpenAI && openAIConfig) { clientOptions.streamRate = openAIConfig.streamRate; + clientOptions.titleModel = openAIConfig.titleModel; } /** @type {undefined | TBaseEndpoint} */ @@ -140,6 +141,7 @@ const initializeClient = async ({ }, clientOptions, ); + clientOptions.modelOptions.user = req.user.id; const options = getLLMConfig(apiKey, clientOptions); if (!clientOptions.streamRate) { return options; diff --git a/api/server/services/Endpoints/openAI/llm.js b/api/server/services/Endpoints/openAI/llm.js index c12f835f2fc..af19ece486e 100644 --- a/api/server/services/Endpoints/openAI/llm.js +++ b/api/server/services/Endpoints/openAI/llm.js @@ -9,6 +9,7 @@ const { isEnabled } = require('~/server/utils'); * @param {Object} options - Additional options for configuring the LLM. * @param {Object} [options.modelOptions] - Model-specific options. * @param {string} [options.modelOptions.model] - The name of the model to use. + * @param {string} [options.modelOptions.user] - The user ID * @param {number} [options.modelOptions.temperature] - Controls randomness in output generation (0-2). * @param {number} [options.modelOptions.top_p] - Controls diversity via nucleus sampling (0-1). * @param {number} [options.modelOptions.frequency_penalty] - Reduces repetition of token sequences (-2 to 2). @@ -23,13 +24,13 @@ const { isEnabled } = require('~/server/utils'); * @param {boolean} [options.streaming] - Whether to use streaming mode. * @param {Object} [options.addParams] - Additional parameters to add to the model options. * @param {string[]} [options.dropParams] - Parameters to remove from the model options. + * @param {string|null} [endpoint=null] - The endpoint name * @returns {Object} Configuration options for creating an LLM instance. */ -function getLLMConfig(apiKey, options = {}) { +function getLLMConfig(apiKey, options = {}, endpoint = null) { const { modelOptions = {}, reverseProxyUrl, - useOpenRouter, defaultQuery, headers, proxy, @@ -56,9 +57,14 @@ function getLLMConfig(apiKey, options = {}) { }); } + let useOpenRouter; /** @type {OpenAIClientOptions['configuration']} */ const configOptions = {}; - if (useOpenRouter || (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter))) { + if ( + (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter)) || + (endpoint && endpoint.toLowerCase().includes(KnownEndpoints.openrouter)) + ) { + useOpenRouter = true; llmConfig.include_reasoning = true; configOptions.baseURL = reverseProxyUrl; configOptions.defaultHeaders = Object.assign( @@ -118,6 +124,13 @@ function getLLMConfig(apiKey, options = {}) { llmConfig.organization = process.env.OPENAI_ORGANIZATION; } + if (useOpenRouter && llmConfig.reasoning_effort != null) { + llmConfig.reasoning = { + effort: llmConfig.reasoning_effort, + }; + delete llmConfig.reasoning_effort; + } + return { /** @type {OpenAIClientOptions} */ llmConfig, diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 7b26093d62d..a467f6a29a7 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,4 +1,3 @@ -// Code Files const axios = require('axios'); const FormData = require('form-data'); const { getCodeBaseURL } = require('@librechat/agents'); @@ -16,7 +15,8 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024; async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { try { const baseURL = getCodeBaseURL(); - const response = await axios({ + /** @type {import('axios').AxiosRequestConfig} */ + const options = { method: 'get', url: `${baseURL}/download/${fileIdentifier}`, responseType: 'stream', @@ -25,10 +25,22 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { 'X-API-Key': apiKey, }, timeout: 15000, - }); + }; + + if (process.env.PROXY) { + options.proxy = { + host: process.env.PROXY, + protocol: process.env.PROXY.startsWith('https') ? 'https' : 'http', + }; + } + const response = await axios(options); return response; } catch (error) { + logAxiosError({ + message: `Error downloading code environment file stream: ${error.message}`, + error, + }); throw new Error(`Error downloading file: ${error.message}`); } } @@ -54,7 +66,8 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' form.append('file', stream, filename); const baseURL = getCodeBaseURL(); - const response = await axios.post(`${baseURL}/upload`, form, { + /** @type {import('axios').AxiosRequestConfig} */ + const options = { headers: { ...form.getHeaders(), 'Content-Type': 'multipart/form-data', @@ -64,7 +77,16 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' }, maxContentLength: MAX_FILE_SIZE, maxBodyLength: MAX_FILE_SIZE, - }); + }; + + if (process.env.PROXY) { + options.proxy = { + host: process.env.PROXY, + protocol: process.env.PROXY.startsWith('https') ? 'https' : 'http', + }; + } + + const response = await axios.post(`${baseURL}/upload`, form, options); /** @type {{ message: string; session_id: string; files: Array<{ fileId: string; filename: string }> }} */ const result = response.data; diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 9630f0bd875..a1ccd7643bd 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -4,7 +4,9 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider'); const { inputSchema, logAxiosError, extractBaseURL, processModelData } = require('~/utils'); const { OllamaClient } = require('~/app/clients/OllamaClient'); +const { isUserProvided } = require('~/server/utils'); const getLogStores = require('~/cache/getLogStores'); +const { logger } = require('~/config'); /** * Splits a string by commas and trims each resulting value. @@ -42,7 +44,7 @@ const fetchModels = async ({ user, apiKey, baseURL, - name = 'OpenAI', + name = EModelEndpoint.openAI, azure = false, userIdQuery = false, createTokenConfig = true, @@ -64,12 +66,19 @@ const fetchModels = async ({ try { const options = { - headers: { - Authorization: `Bearer ${apiKey}`, - }, + headers: {}, timeout: 5000, }; + if (name === EModelEndpoint.anthropic) { + options.headers = { + 'x-api-key': apiKey, + 'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01', + }; + } else { + options.headers.Authorization = `Bearer ${apiKey}`; + } + if (process.env.PROXY) { options.httpsAgent = new HttpsProxyAgent(process.env.PROXY); } @@ -129,9 +138,6 @@ const fetchOpenAIModels = async (opts, _models = []) => { // .split('/deployments')[0] // .concat(`/models?api-version=${azure.azureOpenAIApiVersion}`); // apiKey = azureOpenAIApiKey; - } else if (process.env.OPENROUTER_API_KEY) { - reverseProxyUrl = 'https://openrouter.ai/api/v1'; - apiKey = process.env.OPENROUTER_API_KEY; } if (reverseProxyUrl) { @@ -151,7 +157,7 @@ const fetchOpenAIModels = async (opts, _models = []) => { baseURL, azure: opts.azure, user: opts.user, - name: baseURL, + name: EModelEndpoint.openAI, }); } @@ -160,7 +166,7 @@ const fetchOpenAIModels = async (opts, _models = []) => { } if (baseURL === openaiBaseURL) { - const regex = /(text-davinci-003|gpt-|o\d+-)/; + const regex = /(text-davinci-003|gpt-|o\d+)/; const excludeRegex = /audio|realtime/; models = models.filter((model) => regex.test(model) && !excludeRegex.test(model)); const instructModels = models.filter((model) => model.includes('instruct')); @@ -218,7 +224,7 @@ const getOpenAIModels = async (opts) => { return models; } - if (userProvidedOpenAI && !process.env.OPENROUTER_API_KEY) { + if (userProvidedOpenAI) { return models; } @@ -234,13 +240,71 @@ const getChatGPTBrowserModels = () => { return models; }; -const getAnthropicModels = () => { +/** + * Fetches models from the Anthropic API. + * @async + * @function + * @param {object} opts - The options for fetching the models. + * @param {string} opts.user - The user ID to send to the API. + * @param {string[]} [_models=[]] - The models to use as a fallback. + */ +const fetchAnthropicModels = async (opts, _models = []) => { + let models = _models.slice() ?? []; + let apiKey = process.env.ANTHROPIC_API_KEY; + const anthropicBaseURL = 'https://api.anthropic.com/v1'; + let baseURL = anthropicBaseURL; + let reverseProxyUrl = process.env.ANTHROPIC_REVERSE_PROXY; + + if (reverseProxyUrl) { + baseURL = extractBaseURL(reverseProxyUrl); + } + + if (!apiKey) { + return models; + } + + const modelsCache = getLogStores(CacheKeys.MODEL_QUERIES); + + const cachedModels = await modelsCache.get(baseURL); + if (cachedModels) { + return cachedModels; + } + + if (baseURL) { + models = await fetchModels({ + apiKey, + baseURL, + user: opts.user, + name: EModelEndpoint.anthropic, + tokenKey: EModelEndpoint.anthropic, + }); + } + + if (models.length === 0) { + return _models; + } + + await modelsCache.set(baseURL, models); + return models; +}; + +const getAnthropicModels = async (opts = {}) => { let models = defaultModels[EModelEndpoint.anthropic]; if (process.env.ANTHROPIC_MODELS) { models = splitAndTrim(process.env.ANTHROPIC_MODELS); + return models; } - return models; + if (isUserProvided(process.env.ANTHROPIC_API_KEY)) { + return models; + } + + try { + return await fetchAnthropicModels(opts, models); + } catch (error) { + logger.error('Error fetching Anthropic models:', error); + return models; + } }; const getGoogleModels = () => { diff --git a/api/server/services/ModelService.spec.js b/api/server/services/ModelService.spec.js index a383db1e3c9..fb4481f8403 100644 --- a/api/server/services/ModelService.spec.js +++ b/api/server/services/ModelService.spec.js @@ -161,22 +161,6 @@ describe('getOpenAIModels', () => { expect(models).toEqual(expect.arrayContaining(['openai-model', 'openai-model-2'])); }); - it('attempts to use OPENROUTER_API_KEY if set', async () => { - process.env.OPENROUTER_API_KEY = 'test-router-key'; - const expectedModels = ['model-router-1', 'model-router-2']; - - axios.get.mockResolvedValue({ - data: { - data: expectedModels.map((id) => ({ id })), - }, - }); - - const models = await getOpenAIModels({ user: 'user456' }); - - expect(models).toEqual(expect.arrayContaining(expectedModels)); - expect(axios.get).toHaveBeenCalled(); - }); - it('utilizes proxy configuration when PROXY is set', async () => { axios.get.mockResolvedValue({ data: { @@ -368,15 +352,15 @@ describe('splitAndTrim', () => { }); describe('getAnthropicModels', () => { - it('returns default models when ANTHROPIC_MODELS is not set', () => { + it('returns default models when ANTHROPIC_MODELS is not set', async () => { delete process.env.ANTHROPIC_MODELS; - const models = getAnthropicModels(); + const models = await getAnthropicModels(); expect(models).toEqual(defaultModels[EModelEndpoint.anthropic]); }); - it('returns models from ANTHROPIC_MODELS when set', () => { + it('returns models from ANTHROPIC_MODELS when set', async () => { process.env.ANTHROPIC_MODELS = 'claude-1, claude-2 '; - const models = getAnthropicModels(); + const models = await getAnthropicModels(); expect(models).toEqual(['claude-1', 'claude-2']); }); }); diff --git a/api/typedefs.js b/api/typedefs.js index cc7ae41895f..3045d9543b6 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -766,36 +766,6 @@ * @memberof typedefs */ -/** - * @exports MongoFile - * @typedef {import('~/models/schema/fileSchema.js').MongoFile} MongoFile - * @memberof typedefs - */ - -/** - * @exports ToolCallData - * @typedef {import('~/models/schema/toolCallSchema.js').ToolCallData} ToolCallData - * @memberof typedefs - */ - -/** - * @exports MongoUser - * @typedef {import('~/models/schema/userSchema.js').MongoUser} MongoUser - * @memberof typedefs - */ - -/** - * @exports MongoProject - * @typedef {import('~/models/schema/projectSchema.js').MongoProject} MongoProject - * @memberof typedefs - */ - -/** - * @exports MongoPromptGroup - * @typedef {import('~/models/schema/promptSchema.js').MongoPromptGroup} MongoPromptGroup - * @memberof typedefs - */ - /** * @exports uploadImageBuffer * @typedef {import('~/server/services/Files/process').uploadImageBuffer} uploadImageBuffer diff --git a/api/utils/debug.js b/api/utils/debug.js deleted file mode 100644 index 68599eea387..00000000000 --- a/api/utils/debug.js +++ /dev/null @@ -1,56 +0,0 @@ -const levels = { - NONE: 0, - LOW: 1, - MEDIUM: 2, - HIGH: 3, -}; - -let level = levels.HIGH; - -module.exports = { - levels, - setLevel: (l) => (level = l), - log: { - parameters: (parameters) => { - if (levels.HIGH > level) { - return; - } - console.group(); - parameters.forEach((p) => console.log(`${p.name}:`, p.value)); - console.groupEnd(); - }, - functionName: (name) => { - if (levels.MEDIUM > level) { - return; - } - console.log(`\nEXECUTING: ${name}\n`); - }, - flow: (flow) => { - if (levels.LOW > level) { - return; - } - console.log(`\n\n\nBEGIN FLOW: ${flow}\n\n\n`); - }, - variable: ({ name, value }) => { - if (levels.HIGH > level) { - return; - } - console.group(); - console.group(); - console.log(`VARIABLE ${name}:`, value); - console.groupEnd(); - console.groupEnd(); - }, - request: () => (req, res, next) => { - if (levels.HIGH > level) { - return next(); - } - console.log('Hit URL', req.url, 'with following:'); - console.group(); - console.log('Query:', req.query); - console.log('Body:', req.body); - console.groupEnd(); - return next(); - }, - }, -}; diff --git a/api/utils/tokens.js b/api/utils/tokens.js index 34c6df4cf4c..8edfb0a31c4 100644 --- a/api/utils/tokens.js +++ b/api/utils/tokens.js @@ -13,6 +13,7 @@ const openAIModels = { 'gpt-4-32k-0613': 32758, // -10 from max 'gpt-4-1106': 127500, // -500 from max 'gpt-4-0125': 127500, // -500 from max + 'gpt-4.5': 127500, // -500 from max 'gpt-4o': 127500, // -500 from max 'gpt-4o-mini': 127500, // -500 from max 'gpt-4o-2024-05-13': 127500, // -500 from max diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 5a963c385f3..d4dbb30498b 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -103,6 +103,16 @@ describe('getModelMaxTokens', () => { ); }); + test('should return correct tokens for gpt-4.5 matches', () => { + expect(getModelMaxTokens('gpt-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-4.5']); + expect(getModelMaxTokens('gpt-4.5-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-4.5'], + ); + expect(getModelMaxTokens('openai/gpt-4.5-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-4.5'], + ); + }); + test('should return correct tokens for Anthropic models', () => { const models = [ 'claude-2.1', diff --git a/client/package.json b/client/package.json index 5aa8293f525..96b402e7471 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.7.7-rc1", + "version": "v0.7.7", "description": "", "type": "module", "scripts": { diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 51f46456371..af0f7bdc8e5 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -8,8 +8,8 @@ import { import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled'; import type { CodeEditorRef } from '@codesandbox/sandpack-react'; import type { ArtifactFiles, Artifact } from '~/common'; +import { useEditArtifact, useGetStartupConfig } from '~/data-provider'; import { sharedFiles, sharedOptions } from '~/utils/artifacts'; -import { useEditArtifact } from '~/data-provider'; import { useEditorContext } from '~/Providers'; const createDebouncedMutation = ( @@ -124,6 +124,17 @@ export const ArtifactCodeEditor = memo(function ({ sharedProps: Partial; editorRef: React.MutableRefObject; }) { + const { data: config } = useGetStartupConfig(); + const options: typeof sharedOptions = useMemo(() => { + if (!config) { + return sharedOptions; + } + return { + ...sharedOptions, + bundlerURL: config.bundlerURL, + }; + }, [config]); + if (Object.keys(files).length === 0) { return null; } @@ -135,7 +146,7 @@ export const ArtifactCodeEditor = memo(function ({ ...files, ...sharedFiles, }} - options={{ ...sharedOptions }} + options={options} {...sharedProps} template={template} > diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index 9cb06d413cc..d3d147929f3 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -7,6 +7,7 @@ import { import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { ArtifactFiles } from '~/common'; import { sharedFiles, sharedOptions } from '~/utils/artifacts'; +import { useGetStartupConfig } from '~/data-provider'; import { useEditorContext } from '~/Providers'; export const ArtifactPreview = memo(function ({ @@ -23,6 +24,8 @@ export const ArtifactPreview = memo(function ({ previewRef: React.MutableRefObject; }) { const { currentCode } = useEditorContext(); + const { data: config } = useGetStartupConfig(); + const artifactFiles = useMemo(() => { if (Object.keys(files).length === 0) { return files; @@ -38,6 +41,17 @@ export const ArtifactPreview = memo(function ({ }, }; }, [currentCode, files, fileKey]); + + const options: typeof sharedOptions = useMemo(() => { + if (!config) { + return sharedOptions; + } + return { + ...sharedOptions, + bundlerURL: config.bundlerURL, + }; + }, [config]); + if (Object.keys(artifactFiles).length === 0) { return null; } @@ -48,7 +62,7 @@ export const ArtifactPreview = memo(function ({ ...artifactFiles, ...sharedFiles, }} - options={{ ...sharedOptions }} + options={options} {...sharedProps} template={template} > diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index b997060c61f..ddf08976eb8 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -109,7 +109,9 @@ const ContentParts = memo( return val; }) } - label={isSubmitting ? localize('com_ui_thinking') : localize('com_ui_thoughts')} + label={ + isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts') + } /> )} diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 28910d03154..41ee52453f7 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -29,6 +29,7 @@ const Image = ({ height, width, placeholderDimensions, + className, }: { imagePath: string; altText: string; @@ -38,6 +39,7 @@ const Image = ({ height?: string; width?: string; }; + className?: string; }) => { const [isLoaded, setIsLoaded] = useState(false); const containerRef = useRef(null); @@ -57,7 +59,12 @@ const Image = ({ return (
-
+
-
+
+ +
+