Compare commits

...

156 Commits

Author SHA1 Message Date
Danny Avila
49753a35e5 v0.7.0 (#2273) 2024-04-01 19:24:01 -04:00
Danny Avila
1605ef3793 🐳 hotfix: Tag Images Workflow Update (#2272) 2024-04-01 19:04:37 -04:00
Danny Avila
8b3f80fe24 🐳 hotfix: Necessary Dockerfile Update (#2271)
* chore: remove version comment from pre-commit shell script

* chore: Dockerfile update
2024-04-01 18:46:12 -04:00
bsu3338
038063d4d1 🐞Fix: Stable Diffusion User Directory (#2270) 2024-04-01 15:55:44 -04:00
Danny Avila
5c8b16fbaf v0.7.0 (#2266)
*  v0.7.0

* chore: gitignore

* 🐳 ci: update release image workflows
2024-04-01 15:48:57 -04:00
Danny Avila
aff219c655 📋 fix: Ensure Textarea Resizes in Clipboard Edge Case (#2268)
* chore: ts-ignore fake conversation data used for testing

* chore(useTextarea): import helper functions to declutter hook

* fix(Textarea): reset textarea value explicitly by resetting `textAreaRef.current.value`
2024-04-01 13:40:21 -04:00
Danny Avila
d07396d308 🐞 fix: Handle Empty Model Error in Assistants Form (#2265) 2024-04-01 09:20:11 -04:00
pxz2016
cc92597f14 🐞 fix: Handle Garbled Chinese Characters in File Upload (#2261)
Co-authored-by: 彭修照 <pengxiuzhao.uh@haier.com>
2024-04-01 08:25:36 -04:00
Danny Avila
4854b39f41 🚀 feat: Add CLI Helper Scripts to API Container Image (#2257) 2024-03-31 18:59:07 -04:00
Danny Avila
bb8a40dd98 🎨 fix: Optimize StableDiffusion API Tool and Fix for Assistants Usage (#2253)
* chore: update docs

* fix(StableDiffusion): optimize API responses and file handling, return expected metadata for Assistants endpoint
2024-03-30 20:09:59 -04:00
Danny Avila
56ea0f9ae7 🐳 feat: RAG for Default Docker Compose Files + Docs Update (#2246)
* refactor(deploy-compose.yml): use long-syntax to avoid implicit folder creation of librechat.yaml

* refactor(docker-compose.override.yml.example): use long-syntax to avoid implicit folder creation of librechat.yaml

* chore: add simple health check for RAG_API_URL

* chore: improve axios error handling, adding `logAxiosError`

* chore: more informative message detailing RAG_API_URL path

* feat: add rag_api and vectordb to default compose file

* chore(rag.yml): update standalone rag compose file to use RAG_PORT

* chore: documentation updates

* docs: Update rag_api.md with images

* Update rag_api.md

* Update rag_api.md, assistants clarification

* add RAG API note to breaking changes
2024-03-29 21:15:36 -04:00
Danny Avila
6a6b2e79b0 🔧 fix: Improve Assistants File Citation & Download Handling (#2248)
* fix(processMessages): properly handle assistant file citations and add sources list

* feat: improve file download UX by making any downloaded files accessible within the app post-download

* refactor(processOpenAIImageOutput): correctly handle two different outputs for images since OpenAI generates a file in their storage, shares filepath for image rendering

* refactor: create `addFileToCache` helper to use across frontend

* refactor: add ImageFile parts to cache on processing content stream
2024-03-29 19:09:16 -04:00
Danny Avila
bc2a628902 🌍 fix(Translations): Map Partial langCode and Add Unit Tests (#2240) 2024-03-29 12:17:07 -04:00
Danny Avila
dec7879cc1 refactor(loadConfigModels): Stricter Default Model Fallback (#2239)
* chore: add TEndpoint type/typedef

* refactor(loadConfigModels.spec): stricter default model matching (fails with current impl.)

* refactor(loadConfigModels): return default models on endpoint basis and not fetch basis

* refactor: rename `uniqueKeyToNameMap` to `uniqueKeyToEndpointsMap` for clarity
2024-03-29 11:49:38 -04:00
Danny Avila
0a8118deed 🗨️ fix(useSSE): Prevent 'New Chat' Title after Regenerating Initial Message (#2238) 2024-03-29 10:56:51 -04:00
Raí Santos
59a8165379 🌍 : Updated & Added new Portuguese and Spanish Translations (#2228)
* 🌍 : Updated & Added news Portuguese and Spanish Translations

* fix: \' to "

* fix(Br.tsx): revert Snyk placeholders

* fix(Es.tsx): revert Snyk placeholders

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2024-03-29 10:50:09 -04:00
Danny Avila
3a1d07136c refactor(loadConfigModels): Fallback to Default Models if Fetch Fails (#2236) 2024-03-29 10:43:36 -04:00
Danny Avila
a00756c469 ⬇️ feat: Assistant File Downloads (#2234)
* WIP: basic route for file downloads and file strategy for generating readablestream to pipe as res

* chore(DALLE3): add typing for OpenAI client

* chore: add `CONSOLE_JSON` notes to dotenv.md

* WIP: first pass OpenAI Assistants File Output handling

* feat: first pass assistants output file download from openai

* chore: yml vs. yaml variation to .gitignore for `librechat.yml`

* refactor(retrieveAndProcessFile): remove redundancies

* fix(syncMessages): explicit sort of apiMessages to fix message order on abort

* chore: add logs for warnings and errors, show toast on frontend

* chore: add logger where console was still being used
2024-03-29 08:23:38 -04:00
Fuegovic
7945fea0f9 ✏️ doc update: dotenv.md (#2226) 2024-03-27 16:29:40 -04:00
Ivan Dachev
84656b9812 💽 feat: Add Script for User Stats (#2224) 2024-03-27 14:41:29 -04:00
Fuegovic
b5d25f5e4f 🔎 chore: bump meilisearch v1.7 / v0.38.0 (#2175)
* 🔎 chore: bump meilisearch v1.7 / v0.38.0

* ✏️ breaking_changes.md
2024-03-27 10:08:20 -04:00
Ivan Dachev
d4b0af3dba 💽 feat: Add CONSOLE_JSON for deploying to GCP K8S env (#2146)
* Add CONSOLE_JSON

* Update example env

* Moved to utils
2024-03-27 10:07:04 -04:00
suzuki.sh
57d1f12574 🔗 docs: Fix Link to Code of Conduct (#2206)
Fix link to Code of Conduct
2024-03-26 19:29:22 -04:00
Danny Avila
182c9f7080 🔍 chore: Clean Up Documentation Pt. 4 (#2220) 2024-03-26 14:02:22 -04:00
Danny Avila
5df0ec06ea 🔍 chore: Clean Up Documentation Part 3 (#2219) 2024-03-26 13:57:25 -04:00
Danny Avila
ea54cf03e9 🔍 chore: Clean Up Documentation Part 2 (#2218) 2024-03-26 13:48:20 -04:00
Danny Avila
7f83a060a0 🔍 chore: Clean Up Documentation (#2217)
* fix(initializeClient.spec.js): remove condition failing test on local installations

* docs: remove comments and invalid html as is required by embeddings generator and add new documentation guidelines
2024-03-26 13:40:00 -04:00
Danny Avila
2259bf8b03 🚀 feat: Add GitHub Actions Workflow for Generating Docs Embeddings (#2216) 2024-03-26 11:57:04 -04:00
Danny Avila
5c3c28009f 🧹 chore: Update Docker Docs & Make cache field Optional for Custom Config (#2211)
* docs: updating docker

* fix(customConfig): make `cache` field optional as intended (though not recommended for local setups)
2024-03-26 05:45:20 -04:00
Danny Avila
f55bd3d0e9 🎨 style: Ensure Side Panel state Remains on Refresh (#2210) 2024-03-26 05:21:40 -04:00
Danny Avila
718572b7c8 🎨 style: Refine SidePanel and Textarea Styling (#2209)
* experimental: use TextareaAutosize wrapper with useLayoutEffect to hopefully fix random textarea jankiness

* fix(Textarea): force a resize when placeholder text changes

* style(ScrollToBottom): update styling for scroll button

* style: memoize values and improve side panel toggle states

* refactor(SidePanel): more control for toggle states, new hide panel button, and improve toggle state logic

* chore: hide resizable panel handle on smaller screens
2024-03-26 04:19:51 -04:00
Florian Kohrt
cb62847838 📖 docs: Add details for Azure OpenAI Assistants (#2173)
The default `.env` contains the line `ASSISTANTS_API_KEY=user_provided`. When pre-configuring Azure OpenAI models, this setting makes it impossible to use assistants due to a missing user provided key. Only by commenting the line out the Azure setup works.
2024-03-25 18:27:36 -04:00
Danny Avila
3ef46132eb 🐞 fix(client): Prevent Async Reset of Latest Message (#2203)
* refactor: use debug statement runStepCompleted message

* fix(ChatRoute): prevent use of `newConversation` from reseting `latestMessage`, which would fire asynchronously and finalize after `latestMessage` was already correctly set
2024-03-25 11:16:18 -04:00
Danny Avila
8fc52348e8 🌟 fix: Handle Assistants Edge Cases, Improve Filter Styling (#2201)
* fix(assistants): default query to limit of 100 and `desc` order

* refactor(useMultiSearch): use object as params and fix styling for assistants

* feat: informative message for thread initialization failing due to long message
2024-03-25 08:55:33 -04:00
Fuegovic
a4f4ec85f8 🧑‍💻docs: Update General Docs and Contribution Guidelines (#2194)
* doc upddate: documentation_guidelines.md

* doc upddate: how_to_contribute.md

* doc upddate: testing.md / how_to_contribute.md

* doc upddate: translation_contribution.md/testing.md/how_to_contribute.md

* doc upddate: coding_conventions.md

* fix formatting: how_to_contribute.md

* fix formatting (again) : how_to_contribute.md
2024-03-25 07:26:43 -04:00
Danny Avila
f86d80de59 🔧 fix(assistants): Vision minor fix & Add Docs (#2196)
* 👓 fix(assistants): Only Retrieve Assistant Data for Vision Requests if attachments exist in Host Storage

* docs: add  capability
2024-03-25 00:02:54 -04:00
Danny Avila
798e8763d0 👓 feat: Vision Support for Assistants (#2195)
* refactor(assistants/chat): use promises to speed up initialization, initialize shared variables, include `attachedFileIds` to streamRunManager

* chore: additional typedefs

* fix(OpenAIClient): handle edge case where attachments promise is resolved

* feat: createVisionPrompt

* feat: Vision Support for Assistants
2024-03-24 23:43:00 -04:00
Danny Avila
1f0fb497f8 🎉 feat: Optimizations and Anthropic Title Generation (#2184)
* feat: add claude-3-haiku-20240307 to default anthropic list

* refactor: optimize `saveMessage` calls mid-stream via throttling

* chore: remove addMetadata operations and consolidate in BaseClient

* fix(listAssistantsForAzure): attempt to specify correct model mapping as accurately as possible (#2177)

* refactor(client): update last conversation setup with current assistant model, call newConvo again when assistants load to allow fast initial load and ensure assistant model is always the default, not the last selected model

* refactor(cache): explicitly add TTL of 2 minutes when setting titleCache and add default TTL of 10 minutes to abortKeys cache

* feat(AnthropicClient): conversation titling using Anthropic Function Calling

* chore: remove extraneous token usage logging

* fix(convos): unhandled edge case for conversation grouping (undefined conversation)

* style: Improved style of Search Bar after recent UI update

* chore: remove unused code, content part helpers

* feat: always show code option
2024-03-23 20:21:40 -04:00
Florian Kohrt
8e7816468d 📚 docs: Fix Broken Links (#2171)
Fix broken links to the custom config file on `timeoutMs` and `supportedIds`.
2024-03-23 11:05:52 -04:00
Danny Avila
45a95acec2 📂 feat: RAG Improvements (#2169)
* feat: new vector file processing strategy

* chore: remove unused client files

* chore: remove more unused client files

* chore: remove more unused client files and move used to new dir

* chore(DataIcon): add className

* WIP: Model Endpoint Settings Update, draft additional context settings

* feat: improve parsing for augmented prompt, add full context option

* chore: remove volume mounting from rag.yml as no longer necessary
2024-03-22 19:07:08 -04:00
Danny Avila
f427ad792a 🚀 feat: Assistants Streaming (#2159)
* chore: bump openai to 4.29.0 and npm audit fix

* chore: remove unnecessary stream field from ContentData

* feat: new enum and types for AssistantStreamEvent

* refactor(AssistantService): remove stream field and add conversationId to text ContentData
> - return `finalMessage` and `text` on run completion
> - move `processMessages` to services/Threads to avoid circular dependencies with new stream handling
> - refactor(processMessages/retrieveAndProcessFile): add new `client` field to differentiate new RunClient type

* WIP: new assistants stream handling

* chore: stores messages to StreamRunManager

* chore: add additional typedefs

* fix: pass req and openai to StreamRunManager

* fix(AssistantService): pass openai as client to `retrieveAndProcessFile`

* WIP: streaming tool i/o, handle in_progress and completed run steps

* feat(assistants): process required actions with streaming enabled

* chore: condense early return check for useSSE useEffect

* chore: remove unnecessary comments and only handle completed tool calls when not function

* feat: add TTL for assistants run abort cacheKey

* feat: abort stream runs

* fix(assistants): render streaming cursor

* fix(assistants): hide edit icon as functionality is not supported

* fix(textArea): handle pasting edge cases; first, when onChange events wouldn't fire; second, when textarea wouldn't resize

* chore: memoize Conversations

* chore(useTextarea): reverse args order

* fix: load default capabilities when an azure is configured to support assistants, but `assistants` endpoint is not configured

* fix(AssistantSelect): update form assistant model on assistant form select

* fix(actions): handle azure strict validation for function names to fix crud for actions

* chore: remove content data debug log as it fires in rapid succession

* feat: improve UX for assistant errors mid-request

* feat: add tool call localizations and replace any domain separators from azure action names

* refactor(chat): error out tool calls without outputs during handleError

* fix(ToolService): handle domain separators allowing Azure use of actions

* refactor(StreamRunManager): types and throw Error if tool submission fails
2024-03-21 22:42:25 -04:00
Hermes Trismegistus
ed64c76053 📖 docs: Update ShuttleAI Fibonacci Image (#2160) 2024-03-21 22:41:58 -04:00
Danny Avila
25a0487ce5 chore: Revise of PR #2157, move global steps earlier, execute as root 2024-03-21 12:33:30 -04:00
Danny Avila
3f77fe18b7 🐋 chore: Revise of PR #2157, move step earlier 2024-03-21 12:28:40 -04:00
Danny Avila
09de9a2b42 🐋 fix(Dockerfile): add back additional deps., handle permissions, use --no-audit flag on install (#2157) 2024-03-21 12:24:40 -04:00
Danny Avila
a673f62831 🐋 chore: Cleanup Dockerfile (#2156) 2024-03-21 11:18:53 -04:00
Danny Avila
e0dd0381b2 🌑 style(File Manager): Localize and Update Dark Mode Stylings (#2155)
* 🌑 style: Update Dark Mode Stylings for File Manager

* 🌐 feat: localize file manager text

* 🌐 feat: file panel table localization
2024-03-21 10:52:45 -04:00
Hermes Trismegistus
1ee2c32a67 🚀 feat: Add ShuttleAI as Known Endpoint (#2152)
Added new Official Known Endpoint (ShuttleAI)
2024-03-21 09:17:57 -04:00
Flynn
f521040784 🔧 fix(menu): Menu Item Filter Improvements (#2153)
* small-fix: Ensure that fake seperators in model lists do not show in search

* Ensure Plugin search uses correct placeholder and key filtering in search
2024-03-21 09:15:25 -04:00
Marco Beretta
30f6d90cfe 🖌️ style: Improve Dark Theme Accessibility (#2125)
* style: all landing page components

* chore: converted all slate to gray, since slate doesnt work

* style: assistant panel

* style: basic UI components, userprovided, preset

* style: update in multiple components

* fix(PluginStoreDialog): justify-center

* fixed some minor Ui styles

* style(MultiSearch): update dark bg

* style: update Convo styling

* style: lower textarea max height slightly

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-03-21 09:02:00 -04:00
Walber Cardoso
e95c0aaaed 🔧 style(fix): Convo Fade Effect (#2147)
* 🔧 (fix) Convo Fade Effect

* 🔧style(fix): Convo Fade Effect (#2117)

* 🔧 style(fix): Convo Fade Effect (#2117)
2024-03-21 08:39:43 -04:00
Danny Avila
9bab595204 🔬 chore: Add Circular Dependency Check to backend-review (#2149)
* 🔬 chore: Add Circular Dependency check to `backend-review`

* chore: touch random file for workflow trigger

* chore: workflow step order

* chore: update workflow to create empty auth.json file

* fix: attempt empty auth.json creation

* chore: add test_bundle ESLint ignore pattern
2024-03-20 12:15:42 -04:00
Danny Avila
4f17d97eb2 fix(sendEmail): circular dependency 2024-03-20 11:52:05 -04:00
Danny Avila
e4ac58012f 📧 fix: Correct Handling of Self-Signed Certificates in sendEmail (#2148)
- note: To put it in a different way, if you put rejectUnauthorized: true, it means that self-signed certificates should not be allowed. This means, that EMAIL_ALLOW_SELFSIGNED is set to false
2024-03-20 11:48:54 -04:00
Danny Avila
f7761df52c 🗃️ feat: General File Support for OpenAI, Azure, Custom, Anthropic and Google (RAG) (#2143)
* refactor: re-purpose `resendImages` as `resendFiles`

* refactor: re-purpose `resendImages` as `resendFiles`

* feat: upload general files

* feat: embed file during upload

* feat: delete file embeddings on file deletion

* chore(fileConfig): add epub+zip type

* feat(encodeAndFormat): handle non-image files

* feat(createContextHandlers): build context prompt from file attachments and successful RAG

* fix: prevent non-temp files as well as embedded files to be deleted on new conversation

* fix: remove temp_file_id on usage, prevent non-temp files as well as embedded files to be deleted on new conversation

* fix: prevent non-temp files as well as embedded files to be deleted on new conversation

* feat(OpenAI/Anthropic/Google): basic RAG support

* fix: delete `resendFiles` only when true (Default)

* refactor(RAG): update endpoints and pass JWT

* fix(resendFiles): default values

* fix(context/processFile): query unique ids only

* feat: rag-api.yaml

* feat: file upload improved ux for longer uploads

* chore: await embed call and catch embedding errors

* refactor: store augmentedPrompt in Client

* refactor(processFileUpload): throw error if not assistant file upload

* fix(useFileHandling): handle markdown empty mimetype issue

* chore: necessary compose file changes
2024-03-19 20:54:30 -04:00
SailFlorve
af347cccde 🎨 style: HoverButton UI adjustment, change code font (#2017)
* style: HoverButton UI adjustment

* style: make Consolas as default code font

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-03-19 13:55:41 -04:00
Danny Avila
86db0a1043 Revert "🔧 style(fix): Convo Title Fade Effect (#2117)" (#2139)
This reverts commit 1796821888.
2024-03-19 13:54:35 -04:00
Walber Cardoso
1796821888 🔧 style(fix): Convo Title Fade Effect (#2117)
* feat: Improve Google search plugin to assistants

* 🔧 fix(Nav SidePanel): Center buttons when collapsed

* 🔧(fix) Convo title fade effect

* 🔧(fix) Convo title fade effect / remove deletion

* 🔧(fix) Convo title fade effect / remove deletion .env.example

* 🔧(fix) Convo title fade effect

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-03-19 13:43:24 -04:00
Ido Ophir
d8304ec1bb 📋 chore: add requirements.txt to documentation (#2122)
* chore: add requirements.txt to documentation, to ease maintenance

* docs: Update documentation_guidelines.md
2024-03-19 13:38:18 -04:00
Danny Avila
382b303963 🔍 feat: Filter MultiSelect and SelectDropDown (+variants) + CSS fixes for Scrollbar (#2138)
* Initial implementation of MultiSearch. Added implementation to MultiSelect and SelectDropDown and variants

* Update scrollbar styles to prevent breakages on Chrome

* Revert changes to vite.config.ts (redundant for now)

* chore(New Chat): organize imports

* style(scrollbar-transparent): use webkit as standard, expected behavior

* chore: useCallback for mouse enter/leave

* fix(Footer): resolve map key error

* chore: memoize Conversations

* style(MultiSearch): improve multisearch styling

* style: dark mode search input

* fix: react warnings due to unrecognize html props

* chore: debounce OpenAI settings inputs

* fix(useDebouncedInput): only use event value as newValue if not object

---------

Co-authored-by: Flynn <gpg@flyn.ca>
2024-03-19 13:35:10 -04:00
Danny Avila
f51ac74e12 🪰 fix: Azure Parsing and Assistants Payload (#2133)
* fix(azure): fix regex to prevent edge cases

* fix(assistants): pass relevant endpoint options to avoid sending them to API
2024-03-18 19:48:42 -04:00
Danny Avila
7cddd943d0 🔧 feat(actions): Allow Multiple Actions from Same Domain per Assistant (#2120) 2024-03-16 19:40:51 -04:00
Danny Avila
89f6b35e6c 🔧 fix: Remove Unique Index from Actions Model and Initialize Empty Actions for Deletion (#2118) 2024-03-16 18:53:43 -04:00
Danny Avila
a8cdd3460c 🔧 feat: Share Assistant Actions between Users (#2116)
* fix: remove unique field from assistant_id, which can be shared between different users

* refactor: remove unique user fields from actions/assistant queries

* feat: only allow user who saved action to delete it

* refactor: allow deletions for anyone with builder access

* refactor: update user.id when updating assistants/actions records, instead of searching with it

* fix: stringify response data in case it's an object

* fix: correctly handle path input

* fix(decryptV2): handle edge case where value is already decrypted
2024-03-16 16:49:11 -04:00
Marco Beretta
2f90c8764a 🖊️ fix(MessageContent): Error Message typo (#2112) 2024-03-16 13:05:56 -04:00
Fuegovic
39042f8761 🎨 style: Privacy Policy & Terms of Service (#2111) 2024-03-16 13:05:18 -04:00
Danny Avila
a9d2d3fe40 🪙 feat: Assistants Token Balance & other improvements (#2114)
* chore: add assistants to supportsBalanceCheck

* feat(Transaction): getTransactions and refactor export of model

* refactor: use enum: ViolationTypes.TOKEN_BALANCE

* feat(assistants): check balance

* refactor(assistants): only add promptBuffer if new convo (for title), and remove endpoint definition

* refactor(assistants): Count tokens up to the current context window

* fix(Switcher): make Select list explicitly controlled

* feat(assistants): use assistant's default model when no model is specified instead of the last selected assistant, prevent assistant_id from being recorded in non-assistant endpoints

* chore(assistants/chat): import order

* chore: bump librechat-data-provider due to changes
2024-03-15 19:48:42 -04:00
SailFlorve
f848d752e0 🌍 : Update Chinese Translations (#2098) 2024-03-15 16:12:02 -04:00
Fuegovic
8881346889 📑 docs: update .env.example (#2109) 2024-03-15 16:11:31 -04:00
Danny Avila
f769077ab4 🤖 fix(assistants): Default Capabilities and Retrieval Models (#2102) 2024-03-14 20:42:56 -04:00
Danny Avila
5cd5c3bef8 🅰️ feat: Azure OpenAI Assistants API Support (#1992)
* chore: rename dir from `assistant` to plural

* feat: `assistants` field for azure config, spread options in AppService

* refactor: rename constructAzureURL param for azure as `azureOptions`

* chore: bump openai and bun

* chore(loadDefaultModels): change naming of assistant -> assistants

* feat: load azure settings with currect baseURL for assistants' initializeClient

* refactor: add `assistants` flags to groups and model configs, add mapGroupToAzureConfig

* feat(loadConfigEndpoints): initialize assistants endpoint if azure flag `assistants` is enabled

* feat(AppService): determine assistant models on startup, throw Error if none

* refactor(useDeleteAssistantMutation): send model along with assistant id for delete mutations

* feat: support listing and deleting assistants with azure

* feat: add model query to assistant avatar upload

* feat: add azure support for retrieveRun method

* refactor: update OpenAIClient initialization

* chore: update README

* fix(ci): tests passing

* refactor(uploadOpenAIFile): improve logging and use more efficient REST API method

* refactor(useFileHandling): add model to metadata to target Azure region compatible with current model

* chore(files): add azure naming pattern for valid file id recognition

* fix(assistants): initialize openai with first available assistant model if none provided

* refactor(uploadOpenAIFile): add content type for azure, initialize formdata before azure options

* refactor(sleep): move sleep function out of Runs and into `~/server/utils`

* fix(azureOpenAI/assistants): make sure to only overwrite models with assistant models if `assistants` flag is enabled

* refactor(uploadOpenAIFile): revert to old method

* chore(uploadOpenAIFile): use enum for file purpose

* docs: azureOpenAI update guide with more info, examples

* feat: enable/disable assistant capabilities and specify retrieval models

* refactor: optional chain conditional statement in loadConfigModels.js

* docs: add assistants examples

* chore: update librechat.example.yaml

* docs(azure): update note of file upload behavior in Azure OpenAI Assistants

* chore: update docs and add descriptive message about assistant errors

* fix: prevent message submission with invalid assistant or if files loading

* style: update Landing icon & text when assistant is not selected

* chore: bump librechat-data-provider to 0.4.8

* fix(assistants/azure): assign req.body.model for proper azure init to abort runs
2024-03-14 17:21:42 -04:00
Flynn
1b243c6f8c 📜 feat: Customize Privacy Policy & Terms of Service (#2091) 2024-03-14 16:43:18 -04:00
Alexei Smirnov
d4190c9320 🌍 : Update Russian Translation (#2061)
* feat(chore): add missing translations in Ru.tsx

* feat(chore): add missing translation for My Files menu and headers

* change com_ui_my_files to com_ui_nav_files

* move useLocalize above utils

* feat(chore): add missing translation for My Files menu and headers
2024-03-14 11:26:44 -04:00
MACHINSOFT
cba135d456 style: Auth Error and Preset Items Styling (#2069)
* Change the style of the error message.

* ui preset items

* fix style

* Change the color of the border and adjust the background of the selected input
2024-03-14 09:07:55 -04:00
Raí Santos
f27e7c720f 🔧 fix: Convo Corners & Updated Colors (#2046)
* 🔧 fix: Convo Corners & Updated Colors

* refactored code

* chore: JSON.parse with a try/catch block, removed useless useEffect & and restored Focus

* restored typescript

* import all back
2024-03-14 09:04:09 -04:00
Danny Avila
1b8c0f0bfd chore: Update AnthropicIcon.tsx 2024-03-13 19:00:22 -04:00
Vilmondes Queiroz
0f417aaec0 🧹 chore: remove unused import (#2072) 2024-03-11 18:27:29 -04:00
Danny Avila
d1c37e8bde 🧊 style: Adjust Endpoint Icons (#2070)
* 🧊 style: Adjust Endpoint Icons

* Update MessageParts.tsx
2024-03-11 13:40:31 -04:00
Danny Avila
0bd8c2ba00 🌑 style(AnthropicIcon): adjust for Dark Mode 2024-03-11 11:36:54 -04:00
Danny Avila
ebcca16b94 🌐 feat: librechat.yaml from URL (#2064)
* feat: librechat.yaml from URL

* doc update: librechat.yaml from URL

* update dotenv.md - typo

* Update loadCustomConfig.js

* ci: specs for loadCustomConfig

* fix(processFileURL): safe destructuring of saveURL result

---------

Co-authored-by: fuegovic <fueg@live.ca>
Co-authored-by: Fuegovic <32828263+fuegovic@users.noreply.github.com>
2024-03-11 10:52:54 -04:00
MACHINSOFT
f5a754c8be 🖌️ style: Minor UI Updates (#2011)
* UI Design update

* Add an error icon next to the avatar.

* fix

* Change the style of buttons

* fix: avatar
2024-03-11 10:31:32 -04:00
Walber Cardoso
2e77813952 🔧 style(SidePanel): Center buttons when collapsed (#2045)
* feat: Improve Google search plugin to assistants

* 🔧 fix(Nav SidePanel): Center buttons when collapsed
2024-03-11 09:24:38 -04:00
Danny Avila
f307488dd4 ✍️ refactor(Textarea): Optimize Text Input & Enhance UX (#2058)
* refactor(useDebouncedInput): make object as input arg and accept setter

* refactor(ChatForm/Textarea): consolidate textarea/form logic to one component, use react-hook-form, programmatically click send button instead of passing submitMessage, forwardRef and memoize SendButton

* refactor(Textarea): use Controller field value to avoid manual update of ref

* chore: remove forms provider

* chore: memoize AttachFile

* refactor(ChatForm/SendButton): only re-render SendButton when there is text input

* chore: make iconURL bigger

* chore: optimize Root/Nav

* refactor(SendButton): memoize disabled prop based on text

* chore: memoize Nav and ChatForm

* chore: remove textarea ref text on submission

* feat(EditMessage): Make Esc exit the edit mode and dismiss changes when editing a message

* style(MenuItem): Display the ☑️  icon only on the selected model
2024-03-11 09:18:10 -04:00
Fuegovic
f489aee518 📧 update email templates (#2057)
* 📧 chore: update email templates

* 📧 update password reset confirmation
2024-03-11 09:07:09 -04:00
Fuegovic
2f88c5cb8a ✏️ docs: Railway, Traefik, and Improvements (#2060)
* docs: documentation guidelines

* docs: deploy documentation update
2024-03-11 09:06:27 -04:00
Marco Beretta
6fcaeaafe2 🔧 fix(ThemeContext): Listen for Theme Changes (#2037)
* fix(ThemeContext): listen for changes

* fix(Dropdown): theme auto-update not working
2024-03-09 11:36:04 -05:00
Fuegovic
db870e55c3 🔖 chore: update groq models (#2031) 2024-03-09 08:32:08 -05:00
Fuegovic
5d0d02f5f7 🖊️chore: fix deployment guides (#2021) 2024-03-08 08:52:26 -05:00
Danny Avila
40e884b3ec 🖼️ fix: Clipboard Files & File Name Issues (#2015)
* fix: ensure image handling fetchs image to base64 for multiple images

* fix: append file_id's when writing uploaded files

* feat: timestamp files uploaded from clipboard

* chore: add a different fileid+name separator
2024-03-07 12:27:42 -05:00
Danny Avila
18edd2660b 👥 fix(assistants): Improve Error handling (#2012)
* feat: make assistants endpoint appendable since message state is not managed by LibreChat

* fix(ask): search currentMessages for thread_id if it's not defined

* refactor(abortMiddleware): remove use of `overrideProps` and spread unknown fields instead

* chore: remove console.log in `abortConversation`

* refactor(assistants): improve error handling/cancellation flow
2024-03-07 10:50:01 -05:00
Walber Cardoso
d4fe8fc82d 🔍 feat: Add Google Search Tool for Assistants (#1994) 2024-03-07 10:49:48 -05:00
Ido Ophir
a5f4292d2d 🌊 docs: refactor DigitalOcean guide (#2006) 2024-03-07 08:12:39 -05:00
Fuegovic
fbdf1d17ea 💾 chore: Update .env.example (#2004)
* Update .env.example

Make assistants show in the UI by default

* Update dotenv.md
2024-03-07 08:11:32 -05:00
Ido Ophir
11bca134e7 📝 docs: additions to deployment guide (#2001)
* docs: add intro to deployment guide

* doc: update intro

* doc: Add NGINX deployment guide and update reverse proxy link

* doc:: add  reverse proxy pages and weight for the pages

* doc: Update NGINX configuration file

* doc: imporve new doc

* Doc: fix file names

* doc: fix references names + improve the introduction with chatgpt :-)

* doc: update introduction  guide headings
2024-03-07 08:10:44 -05:00
Danny Avila
ab66747e97 🔧 style: Improve UI and UX with Style Fixes and Code Refactors (#2002)
* refactor(useSSE): add useCallback to all event handlers

* chore: remove modelName in defaultAssistantFormValues

* fix(SidePanel): fix layout shift on chrome my removing sidenav scrollbar

* style(ChatForm): match ChatGPT textarea effect styling

* style: fix flickering of old background color on refresh
2024-03-06 17:49:53 -05:00
Marco Beretta
b2ab6fd19d 🖌️ style: update dialog position (#1999)
* style(ChatForm): update styling and fixed style bug

* style:(Dialog): reduced max height  style(Settings): fixed dialog position height

* style(Settings): fixed large screen  position
2024-03-06 17:03:23 -05:00
Fuegovic
ab263c7a50 📝 docs update: Anthropic models + Traversaal (#1995)
* 📝 docs update: Anthropic models + Traversaal

* 📝 docs update: Anthropic models
2024-03-06 16:52:42 -05:00
Marco Beretta
911babd3e0 🖌️ style: Update Light/Dark UI Themes (#1754)
* BIG UI UPDATE

* fix: search bar, dialog template, new chat icon, convo icon and delete/rename button

* moved some color config and a lot of files

* small text fixes and tailwind config refactor

* Update localization and UI styles

* Update styles and add user-select:none to Tooltip component

* Update mobile.css styles for navigation mask and background color

* Update component imports and styles

* Update DeleteButton imports and references

* Update UI components

* Update tooltip delay duration

* Fix styling and update text in various components

* fixed assistant style

* minor style fixes

* revert: removed CreationHeader & CreationPanel

* style: match new styling for SidePanel

* style: match bg-gray-800 to ChatGPT (#212121)

* style: remove slate for gray where applicable to match new light theme

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-03-06 12:05:43 -05:00
Danny Avila
2733c5ebe7 🔎 fix(Traversaal): Recognize authField during Tool Initialization 2024-03-06 10:59:00 -05:00
Danny Avila
959d6153f6 🔎 feat: Traversaal Search Tool (#1991)
* wip: Traversaal Search Tool

* fix(traversaal): properly handle tool error, show error to LLM, log

* feat(traversaal): finish implementation of structured tool

* chore: change traversaal order
2024-03-06 10:25:38 -05:00
Danny Avila
14dd3dd240 🖋️ fix(OpenAIClient): remove typo 2024-03-06 09:19:52 -05:00
Danny Avila
8263ddda3f 🤖 feat(Anthropic): Claude 3 & Vision Support (#1984)
* chore: bump anthropic SDK

* chore: update anthropic config settings (fileSupport, default models)

* feat: anthropic multi modal formatting

* refactor: update vision models and use endpoint specific max long side resizing

* feat(anthropic): multimodal messages, retry logic, and messages payload

* chore: add more safety to trimming content due to whitespace error for assistant messages

* feat(anthropic): token accounting and resending multiple images in progress

* chore: bump data-provider

* feat(anthropic): resendImages feature

* chore: optimize Edit/Ask controllers, switch model back to req model

* fix: false positive of invalid model

* refactor(validateVisionModel): use object as arg, pass in additional/available models

* refactor(validateModel): use helper function, `getModelsConfig`

* feat: add modelsConfig to endpointOption so it gets passed to all clients, use for properly validating vision models

* refactor: initialize default vision model and make sure it's available before assigning it

* refactor(useSSE): avoid resetting model if user selected a new model between request and response

* feat: show rate in transaction logging

* fix: return tokenCountMap regardless of payload shape
2024-03-06 00:04:52 -05:00
Danny Avila
b023c5683d 🛠️ refactor(loadConfigModels): make apiKey and baseURL pairings more versatile (#1985) 2024-03-05 15:42:19 -05:00
Fuegovic
a33db54b81 🔎 update meilisearch to v1.6 / 0.37.0 (#1981)
* 🔎 update meilisearch to v1.6 / 0.37.0

* 🔎 update meilisearch to v1.6 / 0.37.0
2024-03-05 14:36:01 -05:00
Danny Avila
7a6a41a72e 🧪 fix(ci): update failing initializeClient tests with new expected values (#1982)
* fix(ci): update failing tests with new expected values from `getUserKey`

* refactor: safer optional chaining, and ensure apiKey is defined
2024-03-05 14:33:45 -05:00
Fuegovic
2ea6e8c18a 🥷🪦 docs: remove ninja and chatgptBrowser (#1973) 2024-03-04 19:49:34 -05:00
Ido Ophir
7c85b35af0 🌍 : Add Hebrew Translation (#1953)
* feat: add hebrew

* fix: review issues

* fix language options
2024-03-04 17:16:49 -05:00
Fuegovic
eccf7bbbde 🦙 doc: add Ollama to index and update icon (#1967) 2024-03-04 17:16:33 -05:00
Danny Avila
8bef084bfc 🧩 fix(Plugins): Keep User agentModel and Model Validation (#1972)
* fix: do not override model

* temp fix for secondary model validation
2024-03-04 17:07:30 -05:00
Danny Avila
62834e18fb 🪙 fix(config): use new field for balance 2024-03-04 16:37:06 -05:00
Marco Beretta
2da0a7661d 🔧 fix(EditMessage): duplicate text when pasting (#1970)
* fix(EditMessage): duplicate text when pasting on chromium

* add back paste data handling, prevent default behavior

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-03-04 16:27:34 -05:00
Marco Beretta
7d633f4018 🔧 fix(useTextarea): duplicate text when pasting on chromium (#1951) 2024-03-02 15:53:13 -05:00
bsu3338
78f52859c4 📚 docs: Separate LiteLLM and Ollama Documentation (#1948)
* Separate LiteLLM and Ollama Documentation

* Clarify Ollama Setup

* Fix litellm config
2024-03-02 12:42:02 -05:00
Danny Avila
b2ef75e009 🖥️ feat: Match STDOUT Logs with Debug File Logs (#1944)
* chore: improve token balance logging post-request

* feat: match stdout logging with file debug logging when using DEBUG_CONSOLE
2024-03-01 13:42:04 -05:00
Danny Avila
ef86b25dae 👤 feat: Show Default Icon if No Avatar or Username provided (#1943) 2024-03-01 13:08:27 -05:00
Danny Avila
c52ea9490b 📝 feat: Improved Textarea Functionality (#1942)
* feat: paste plain text from apps with rich paste data, improved edit message textarea, improved height resizing for long text

* feat(EditMessage): autofocus

* chore: retain user text color when entering edit mode
2024-03-01 12:46:15 -05:00
Fuegovic
de0cee3f56 🔎docs: update meilisearch instruction (#1930)
* 🔎docs: update meilisearch in mac_install.md

Update the Meilisearch .env variables in `mac_install.md`

* 🔎🐧
2024-03-01 12:32:32 -05:00
Danny Avila
1caa31b035 🐳chore(Dockerfile): add additional steps to prevent arm64 build failure 2024-02-29 10:04:36 -05:00
Danny Avila
ed7d7c2fda 🐳 chore(Dockerfile): replace npm ci with npm install for OS specific builds 2024-02-29 09:46:33 -05:00
Danny Avila
93803323cf 🐳 experimental: Dev Image Workflow & Remove Unused Code (#1928)
* chore: remove unused code in progressCallback, as well as handle reply.trim(), post `getCompletion`

* chore(Dockerfile): remove curl installation

* experimental: dev image parallelized with matrix strategy and building for amd64/arm64 support

* make platforms explicit
2024-02-29 09:24:55 -05:00
Danny Avila
388dc1789b 🛠️ fix: RunManager, AssistantService and useContentHandler Issues (#1920)
* fix(useContentHandler): retain undefined parts and handle them within `ContentParts` rendering

* fix(AssistantService/in_progress): skip empty messages

* refactor(RunManager): create highly specific `seenSteps` Set keys for RunSteps with use of `getDetailsSignature` and `getToolCallSignature`,to ensure changes from polling are always captured
2024-02-28 15:15:45 -05:00
Fuegovic
057fcf6274 🌍 feat: Extend regex to support international usernames (#1918)
* 🌍 Extend regex to support international usernames

* update validators.spec.js
2024-02-28 14:27:57 -05:00
Danny Avila
2f92b54787 🔗 feat: User Provided Base URL for OpenAI endpoints (#1919)
* chore: bump browserslist-db@latest

* refactor(EndpointService): simplify with `generateConfig`, utilize optional baseURL for OpenAI-based endpoints, use `isUserProvided` helper fn wherever needed

* refactor(custom/initializeClient): use standardized naming for common variables

* feat: user provided baseURL for openAI-based endpoints

* refactor(custom/initializeClient): re-order operations

* fix: knownendpoints enum definition and add FetchTokenConfig, bump data-provider

* refactor(custom): use tokenKey dependent on userProvided conditions for caching and fetching endpointTokenConfig, anticipate token rates from custom config

* refactor(custom): assure endpointTokenConfig is only accessed from cache if qualifies for fetching

* fix(ci): update tests for initializeClient based on userProvideURL changes

* fix(EndpointService): correct baseURL env var for assistants: `ASSISTANTS_BASE_URL`

* fix: unnecessary run cancellation on res.close() when response.run is completed

* feat(assistants): user provided URL option

* ci: update tests and add test for `assistants` endpoint

* chore: leaner condition for request closing

* chore: more descriptive error message to provide keys again
2024-02-28 14:27:19 -05:00
Fuegovic
53ae2d7bfb 🤖feat: add multiple known endpoints (#1917)
* feat: add known endpoints

* docs: add known endpoints

* update ai_endpoints.md

remove the groq icon from the example

* Update ai_endpoints.md

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-02-28 08:46:21 -05:00
Marco Beretta
156abe2fca 🔗 feat: NavLinks customization for Help & Faq URL (#1872)
* help and faq

* fix: using only one var

* revert(types.ts): showHelpAndFaq

* Update dotenv.md

* Update dotenv.md
2024-02-27 17:59:56 -05:00
Danny Avila
c37d5568bf 🍞 fix: Minor fixes and improved Bun support (#1916)
* fix(bun): fix bun compatibility to allow gzip header: https://github.com/oven-sh/bun/issues/267#issuecomment-1854460357

* chore: update custom config examples

* fix(OpenAIClient.chatCompletion): remove redundant call of stream.controller.abort() as `break` aborts the request and prevents abort errors when not called redundantly

* chore: bump bun.lockb

* fix: remove result-thinking class when message is no longer streaming

* fix(bun): improve Bun support by forcing use of old method in bun env, also update old methods with new customizable params

* fix(ci): pass tests
2024-02-27 17:51:16 -05:00
Danny Avila
5d887492ea 🤖 docs: Add Groq and other Compatible AI Endpoints (#1915)
* chore: bump bun dependencies

* feat: make `groq` a known endpoint

* docs: compatible ai endpoints

* Update ai_endpoints.md

* Update ai_endpoints.md
2024-02-27 13:42:10 -05:00
Danny Avila
04eeb59d47 🛠️ chore: Abort AI Requests on Close & Remove Verbose Logs for Plugins (#1914)
* chore: remove verbose logging of ChatOpenAI

* feat: abort AI requests on request close
2024-02-27 10:21:06 -05:00
Danny Avila
08d4b3cc8a 🅰️ feat: Azure AI Studio, Models as a Service Support (#1902)
* feat(data-provider): add Azure serverless inference handling through librechat.yaml

* feat(azureOpenAI): serverless inference handling in api

* docs: update docs with new azureOpenAI endpoint config fields and serverless inference endpoint setup

* chore: remove unnecessary checks for apiKey as schema would not allow apiKey to be undefined

* ci(azureOpenAI): update tests for serverless configurations
2024-02-26 19:10:29 -05:00
Raí Santos
6d6b3c9c1d 🌍 : Update Portuguese Translations (#1867)
* 🌍 : Update Portuguese Translations

* 🌍 : Fix Portuguese Translations

* fix(Br): lint errors

---------

Co-authored-by: Berry-13 <81851188+Berry-13@users.noreply.github.com>
2024-02-26 14:37:08 -05:00
Danny Avila
49744d1af9 🔥chore: bump firebase dependency (#1900) 2024-02-26 14:36:48 -05:00
Marco Beretta
b4dc8cc2ad 🖌️ style: auth dark theme (#1862)
* Remove minLength validation and update login link style

* Add theme selector component and update login form styles

* Update styling in Login and LoginForm components

* Update ResetPassword component styles and text color

* Refactor login component and add theme selector

* Add ThemeSelector component to Registration, RequestPasswordReset, and ResetPassword pages

* chore(Login.tsx): remove unused `useCallback`

* chore(Login.tsx) import order

* Update ResetPassword.tsx import order

* Update RequestPasswordReset.tsx import order

* Update Registration.tsx import order

---------

Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
2024-02-26 14:21:17 -05:00
Danny Avila
097a978e5b 🅰️ feat: Azure Config to Allow Different Deployments per Model (#1863)
* wip: first pass for azure endpoint schema

* refactor: azure config to return groupMap and modelConfigMap

* wip: naming and schema changes

* refactor(errorsToString): move to data-provider

* feat: rename to azureGroups, add additional tests, tests all expected outcomes, return errors

* feat(AppService): load Azure groups

* refactor(azure): use imported types, write `mapModelToAzureConfig`

* refactor: move `extractEnvVariable` to data-provider

* refactor(validateAzureGroups): throw on duplicate groups or models; feat(mapModelToAzureConfig): throw if env vars not present, add tests

* refactor(AppService): ensure each model is properly configured on startup

* refactor: deprecate azureOpenAI environment variables in favor of librechat.yaml config

* feat: use helper functions to handle and order enabled/default endpoints; initialize azureOpenAI from config file

* refactor: redefine types as well as load azureOpenAI models from config file

* chore(ci): fix test description naming

* feat(azureOpenAI): use validated model grouping for request authentication

* chore: bump data-provider following rebase

* chore: bump config file version noting significant changes

* feat: add title options and switch azure configs for titling and vision requests

* feat: enable azure plugins from config file

* fix(ci): pass tests

* chore(.env.example): mark `PLUGINS_USE_AZURE` as deprecated

* fix(fetchModels): early return if apiKey not passed

* chore: fix azure config typing

* refactor(mapModelToAzureConfig): return baseURL and headers as well as azureOptions

* feat(createLLM): use `azureOpenAIBasePath`

* feat(parsers): resolveHeaders

* refactor(extractBaseURL): handle invalid input

* feat(OpenAIClient): handle headers and baseURL for azureConfig

* fix(ci): pass `OpenAIClient` tests

* chore: extract env var for azureOpenAI group config, baseURL

* docs: azureOpenAI config setup docs

* feat: safe check of potential conflicting env vars that map to unique placeholders

* fix: reset apiKey when model switches from originally requested model (vision or title)

* chore: linting

* docs: CONFIG_PATH notes in custom_config.md
2024-02-26 14:12:25 -05:00
Andreas
7a55132e42 🔧 feat: optional librechat.yaml path via environment variable (#1858)
Co-authored-by: afel <andreas.feldl@netlight.com>
2024-02-26 13:59:19 -05:00
Arno Angerer
c1a4733d50 📒 docs: Add newline for list to be correctly rendered in UI (#1873)
Currently in the documentation page the bullet list is not rendered correctly. (See first paragraph on this docs page: https://docs.librechat.ai/install/configuration/litellm.html)
2024-02-23 15:29:36 -05:00
Danny Avila
f431c8fb00 🔀 fix: Correct Expected Behavior for Modular Chat Feature (#1871) 2024-02-23 12:14:58 -05:00
Fuegovic
5445d55af2 🐋 docs: update breaking_changes.md (#1864)
add note about the use of the pre-built image in docker-compose.yml
2024-02-23 11:51:17 -05:00
Danny Avila
6a25dd38a4 🗨️ fix: Prevent Resetting Title to 'New Chat' on Follow-Up Message (#1870)
* fix: prevent reseting title to 'New Chat' on follow up message

* chore(useSSE): remove empty line
2024-02-23 10:20:46 -05:00
Fuegovic
ece5d9f588 ✏️docs: add tavily to env.example and dotenv.md (#1866)
* update .env.example

add "TAVILY_API_KEY=" to .env.example

* update dotenv.md

add Tavily to dotenv.md
2024-02-23 10:08:49 -05:00
Danny Avila
5f6d1f3db0 🎨 feat: Create Avatars of Initials Locally (#1869) 2024-02-23 09:23:29 -05:00
Fuegovic
4012dea4ab 🐋 Feat: docker pre-built image by default (#1860)
* 🐋 Feat: docker pre-built image by default

* 🐋 Feat: docker LibreChat ports from .env
2024-02-22 13:20:27 -05:00
Danny Avila
128446601a 🐛 fix: Preserve Default Model in Message Requests (#1857)
* fix: do not remove default model from message request

* chore: bump data-provider
2024-02-21 13:29:21 -05:00
Danny Avila
dd8038b375 🛠️ refactor: Model Loading and Custom Endpoint Error Handling (#1849)
* fix: handle non-assistant role ChatCompletionMessage error

* refactor(ModelController): decouple res.send from loading/caching models

* fix(custom/initializeClient): only fetch custom endpoint models if models.fetch is true

* refactor(validateModel): load models if modelsConfig is not yet cached

* docs: update on file upload rate limiting
2024-02-20 12:57:58 -05:00
Danny Avila
542494fad6 📋 feat: Accumulate Text Parts to Clipboard for Assistant Outputs (#1847) 2024-02-20 09:33:31 -05:00
Danny Avila
64e81392f2 ⬤ style: Uniform Display of Result-Streaming Cursor (#1842) 2024-02-19 22:55:58 -05:00
Danny Avila
a8a19c6caa 🛡️ feat: Model Validation Middleware (#1841)
* refactor: add ViolationTypes enum and add new violation for illegal model requests

* feat: validateModel middleware to protect the backend against illicit requests for unlisted models
2024-02-19 22:47:39 -05:00
Danny Avila
d8038e3b19 📤 refactor: Utilize intermediateReply when message.content is Empty 2024-02-19 11:03:42 -05:00
Danny Avila
ee97179edb 📝 chore: Update README.md 2024-02-19 09:45:59 -05:00
Danny Avila
63a5039fae 🔗 chore: Add Stable Discord and Homepage Links (#1835) 2024-02-19 09:42:57 -05:00
Fuegovic
7442955a1d 📝 docs: add env changes to breaking_changes.md and minor fixes (#1812)
* 📝 docs: add env changes to breacking_changes.md

* 📝 docs: replace example in docker_override.md

* 📝 docs: fix images in zeabur.md
2024-02-19 09:41:07 -05:00
Danny Avila
5291d18f38 🔀 fix: Endpoint Type Mismatch when Switching Conversations (#1834)
* refactor(useUpdateUserKeysMutation): only invalidate the endpoint whose key is being updated by user

* fix(assistants): await `getUserKeyExpiry` call

* chore: fix spinner loading color

* refactor(initializeClient): make known which endpoint api Key is missing

* fix: prevent an `endpointType` mismatch by making it impossible to assign when the `endpointsConfig` doesn't have a `type` defined, also prefer `getQueryData` call to useQuery in useChatHelpers
2024-02-19 01:31:38 -05:00
Danny Avila
d1eb7fcfc7 Update main-image-workflow.yml 2024-02-16 16:05:18 -05:00
Danny Avila
ce1cdea3de Update main-image-workflow.yml 2024-02-16 15:55:12 -05:00
Danny Avila
0da30b9481 Update main-image-workflow.yml 2024-02-16 15:46:09 -05:00
Danny Avila
b7aebf6c51 Update main-image-workflow.yml 2024-02-16 15:43:31 -05:00
Danny Avila
29ee4423a6 🐋 chore: Add Docker Compose Build Latest Main Image workflow (#1819) 2024-02-16 15:37:32 -05:00
474 changed files with 18776 additions and 8224 deletions

View File

@@ -13,9 +13,6 @@
# Server Configuration #
#==================================================#
APP_TITLE=LibreChat
# CUSTOM_FOOTER="My custom footer"
HOST=localhost
PORT=3080
@@ -26,6 +23,13 @@ DOMAIN_SERVER=http://localhost:3080
NO_INDEX=true
#===============#
# JSON Logging #
#===============#
# Use when process console logs in cloud deployment like GCP/AWS
CONSOLE_JSON=false
#===============#
# Debug Logging #
#===============#
@@ -40,38 +44,62 @@ DEBUG_CONSOLE=false
# UID=1000
# GID=1000
#===============#
# Configuration #
#===============#
# Use an absolute path, a relative path, or a URL
# CONFIG_PATH="/alternative/path/to/librechat.yaml"
#===================================================#
# Endpoints #
#===================================================#
# ENDPOINTS=openAI,assistants,azureOpenAI,bingAI,chatGPTBrowser,google,gptPlugins,anthropic
# ENDPOINTS=openAI,assistants,azureOpenAI,bingAI,google,gptPlugins,anthropic
PROXY=
#===================================#
# Known Endpoints - librechat.yaml #
#===================================#
# https://docs.librechat.ai/install/configuration/ai_endpoints.html
# GROQ_API_KEY=
# SHUTTLEAI_KEY=
# OPENROUTER_KEY=
# MISTRAL_API_KEY=
# ANYSCALE_API_KEY=
# FIREWORKS_API_KEY=
# PERPLEXITY_API_KEY=
# TOGETHERAI_API_KEY=
#============#
# Anthropic #
#============#
ANTHROPIC_API_KEY=user_provided
ANTHROPIC_MODELS=claude-1,claude-instant-1,claude-2
# ANTHROPIC_MODELS=claude-3-opus-20240229,claude-3-sonnet-20240229,claude-2.1,claude-2,claude-1.2,claude-1,claude-1-100k,claude-instant-1,claude-instant-1-100k
# ANTHROPIC_REVERSE_PROXY=
#============#
# Azure #
#============#
# AZURE_API_KEY=
AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4
# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo
# PLUGINS_USE_AZURE="true"
AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE
# Note: these variables are DEPRECATED
# Use the `librechat.yaml` configuration for `azureOpenAI` instead
# You may also continue to use them if you opt out of using the `librechat.yaml` configuration
# AZURE_OPENAI_API_INSTANCE_NAME=
# AZURE_OPENAI_API_DEPLOYMENT_NAME=
# AZURE_OPENAI_API_VERSION=
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME=
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME=
# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # Deprecated
# AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4 # Deprecated
# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE # Deprecated
# AZURE_API_KEY= # Deprecated
# AZURE_OPENAI_API_INSTANCE_NAME= # Deprecated
# AZURE_OPENAI_API_DEPLOYMENT_NAME= # Deprecated
# AZURE_OPENAI_API_VERSION= # Deprecated
# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated
# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated
# PLUGINS_USE_AZURE="true" # Deprecated
#============#
# BingAI #
@@ -80,14 +108,6 @@ AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE
BINGAI_TOKEN=user_provided
# BINGAI_HOST=https://cn.bing.com
#============#
# ChatGPT #
#============#
CHATGPT_TOKEN=
CHATGPT_MODELS=text-davinci-002-render-sha
# CHATGPT_REVERSE_PROXY=
#============#
# Google #
#============#
@@ -115,13 +135,13 @@ DEBUG_OPENAI=false
# OPENAI_REVERSE_PROXY=
# OPENAI_ORGANIZATION=
# OPENAI_ORGANIZATION=
#====================#
# Assistants API #
#====================#
# ASSISTANTS_API_KEY=
ASSISTANTS_API_KEY=user_provided
# ASSISTANTS_BASE_URL=
# ASSISTANTS_MODELS=gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
@@ -183,6 +203,14 @@ SERPAPI_API_KEY=
#-----------------
SD_WEBUI_URL=http://host.docker.internal:7860
# Tavily
#-----------------
TAVILY_API_KEY=
# Traversaal
#-----------------
TRAVERSAAL_API_KEY=
# WolframAlpha
#-----------------
WOLFRAM_APP_ID=
@@ -238,6 +266,8 @@ LIMIT_MESSAGE_USER=false
MESSAGE_USER_MAX=40
MESSAGE_USER_WINDOW=1
ILLEGAL_MODEL_REQ_SCORE=5
#========================#
# Balance #
#========================#
@@ -294,15 +324,15 @@ OPENID_IMAGE_URL=
# Email Password Reset #
#========================#
EMAIL_SERVICE=
EMAIL_HOST=
EMAIL_PORT=25
EMAIL_ENCRYPTION=
EMAIL_ENCRYPTION_HOSTNAME=
EMAIL_ALLOW_SELFSIGNED=
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=
EMAIL_SERVICE=
EMAIL_HOST=
EMAIL_PORT=25
EMAIL_ENCRYPTION=
EMAIL_ENCRYPTION_HOSTNAME=
EMAIL_ALLOW_SELFSIGNED=
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=
EMAIL_FROM=noreply@librechat.ai
#========================#
@@ -316,6 +346,16 @@ FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
#===================================================#
# UI #
#===================================================#
APP_TITLE=LibreChat
# CUSTOM_FOOTER="My custom footer"
HELP_AND_FAQ_URL=https://librechat.ai
# SHOW_BIRTHDAY_ICON=true
#==================================================#
# Others #
#==================================================#
@@ -323,15 +363,8 @@ FIREBASE_APP_ID=
# NODE_ENV=
# If using Redis, you should flush the cache after changing any LibreChat settings
# REDIS_URI=
# USE_REDIS=
# Give the AI Icon a Birthday Hat :)
# Will show automatically on February 11th (LibreChat's birthday)
# Set this to false to disable the birthday hat
# Set to true to enable all the time.
# SHOW_BIRTHDAY_ICON=true
# E2E_USER_EMAIL=
# E2E_USER_PASSWORD=
# E2E_USER_PASSWORD=

View File

@@ -19,6 +19,7 @@ module.exports = {
'e2e/playwright-report/**/*',
'packages/data-provider/types/**/*',
'packages/data-provider/dist/**/*',
'packages/data-provider/test_bundle/**/*',
'data-node/**/*',
'meili_data/**/*',
'node_modules/**/*',

View File

@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement here on GitHub or
on the official [Discord Server](https://discord.gg/uDyZ5Tzhct).
on the official [Discord Server](https://discord.librechat.ai).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@@ -8,7 +8,7 @@ If the feature you would like to contribute has not already received prior appro
Please note that a pull request involving a feature that has not been reviewed and approved by the project maintainers may be rejected. We appreciate your understanding and cooperation.
If you would like to discuss the changes you wish to make, join our [Discord community](https://discord.gg/uDyZ5Tzhct), where you can engage with other contributors and seek guidance from the community.
If you would like to discuss the changes you wish to make, join our [Discord community](https://discord.librechat.ai), where you can engage with other contributors and seek guidance from the community.
## Our Standards

View File

@@ -50,7 +50,7 @@ body:
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/CODE_OF_CONDUCT.md)
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
options:
- label: I agree to follow this project's Code of Conduct
required: true

6
.github/SECURITY.md vendored
View File

@@ -12,7 +12,7 @@ When reporting a security vulnerability, you have the following options to reach
- **Option 2: GitHub Issues**: You can initiate first contact via GitHub Issues. However, please note that initial contact through GitHub Issues should not include any sensitive details.
- **Option 3: Discord Server**: You can join our [Discord community](https://discord.gg/5rbRxn4uME) and initiate first contact in the `#issues` channel. However, please ensure that initial contact through Discord does not include any sensitive details.
- **Option 3: Discord Server**: You can join our [Discord community](https://discord.librechat.ai) and initiate first contact in the `#issues` channel. However, please ensure that initial contact through Discord does not include any sensitive details.
_After the initial contact, we will establish a private communication channel for further discussion._
@@ -39,11 +39,11 @@ Please note that as a security-conscious community, we may not always disclose d
This security policy applies to the following GitHub repository:
- Repository: [LibreChat](https://github.com/danny-avila/LibreChat)
- Repository: [LibreChat](https://github.librechat.ai)
## Contact
If you have any questions or concerns regarding the security of our project, please join our [Discord community](https://discord.gg/NGaa9RPCft) and report them in the appropriate channel. You can also reach out to us by [opening an issue](https://github.com/danny-avila/LibreChat/issues/new) on GitHub. Please note that the response time may vary depending on the nature and severity of the inquiry.
If you have any questions or concerns regarding the security of our project, please join our [Discord community](https://discord.librechat.ai) and report them in the appropriate channel. You can also reach out to us by [opening an issue](https://github.com/danny-avila/LibreChat/issues/new) on GitHub. Please note that the response time may vary depending on the nature and severity of the inquiry.
## Acknowledgments

View File

@@ -15,8 +15,9 @@ Please delete any irrelevant options.
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
- [ ] Documentation update
- [ ] Translation update
- [ ] Documentation update
## Testing
@@ -26,6 +27,8 @@ Please describe your test process and include instructions so that we can reprod
## Checklist
Please delete any irrelevant options.
- [ ] My code adheres to this project's style guidelines
- [ ] I have performed a self-review of my own code
- [ ] I have commented in any complex areas of my code
@@ -34,3 +37,4 @@ Please describe your test process and include instructions so that we can reprod
- [ ] I have written tests demonstrating that my changes are effective or that my feature works
- [ ] Local unit tests pass with my changes
- [ ] Any changes dependent on mine have been merged and published in downstream modules.
- [ ] New documents have been locally validated with mkdocs

View File

@@ -35,6 +35,21 @@ jobs:
- name: Install Data Provider
run: npm run build:data-provider
- name: Create empty auth.json file
run: |
mkdir -p api/data
echo '{}' > api/data/auth.json
- name: Check for Circular dependency in rollup
working-directory: ./packages/data-provider
run: |
output=$(npm run rollup:api)
echo "$output"
if echo "$output" | grep -q "Circular dependency"; then
echo "Error: Circular dependency detected!"
exit 1
fi
- name: Run unit tests
run: cd api && npm run test:ci

View File

@@ -1,83 +0,0 @@
name: Docker Compose Build on Tag
# The workflow is triggered when a tag is pushed
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Set up Docker
- name: Set up Docker
uses: docker/setup-buildx-action@v3
# Set up QEMU for cross-platform builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Prepare Docker Build
- name: Build Docker images
run: |
cp .env.example .env
# Tag and push librechat-api
- name: Docker metadata for librechat-api
id: meta-librechat-api
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/librechat-api
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and librechat-api
uses: docker/build-push-action@v5
with:
file: Dockerfile.multi
context: .
push: true
tags: ${{ steps.meta-librechat-api.outputs.tags }}
platforms: linux/amd64,linux/arm64
target: api-build
# Tag and push librechat
- name: Docker metadata for librechat
id: meta-librechat
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/librechat
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and librechat
uses: docker/build-push-action@v5
with:
file: Dockerfile
context: .
push: true
tags: ${{ steps.meta-librechat.outputs.tags }}
platforms: linux/amd64,linux/arm64
target: node

View File

@@ -13,14 +13,27 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: librechat-dev-api
- target: node
file: Dockerfile
image_name: librechat-dev
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Set up Docker
- name: Set up Docker
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Log in to GitHub Container Registry
@@ -38,35 +51,22 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build Docker images
- name: Build Docker images
# Prepare the environment
- name: Prepare environment
run: |
cp .env.example .env
docker build -f Dockerfile.multi --target api-build -t librechat-dev-api .
docker build -f Dockerfile -t librechat-dev .
# Tag and push the images to GitHub Container Registry
- name: Tag and push images to GHCR
run: |
docker tag librechat-dev-api:latest ghcr.io/${{ github.repository_owner }}/librechat-dev-api:${{ github.sha }}
docker push ghcr.io/${{ github.repository_owner }}/librechat-dev-api:${{ github.sha }}
docker tag librechat-dev-api:latest ghcr.io/${{ github.repository_owner }}/librechat-dev-api:latest
docker push ghcr.io/${{ github.repository_owner }}/librechat-dev-api:latest
docker tag librechat-dev:latest ghcr.io/${{ github.repository_owner }}/librechat-dev:${{ github.sha }}
docker push ghcr.io/${{ github.repository_owner }}/librechat-dev:${{ github.sha }}
docker tag librechat-dev:latest ghcr.io/${{ github.repository_owner }}/librechat-dev:latest
docker push ghcr.io/${{ github.repository_owner }}/librechat-dev:latest
# Tag and push the images to Docker Hub
- name: Tag and push images to Docker Hub
run: |
docker tag librechat-dev-api:latest ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev-api:${{ github.sha }}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev-api:${{ github.sha }}
docker tag librechat-dev-api:latest ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev-api:latest
docker push ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev-api:latest
docker tag librechat-dev:latest ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev:${{ github.sha }}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev:${{ github.sha }}
docker tag librechat-dev:latest ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev:latest
docker push ${{ secrets.DOCKERHUB_USERNAME }}/librechat-dev:latest
# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}

View File

@@ -0,0 +1,20 @@
name: 'generate_embeddings'
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'docs/**'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: supabase/embeddings-generator@v0.0.5
with:
supabase-url: ${{ secrets.SUPABASE_URL }}
supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
openai-key: ${{ secrets.OPENAI_DOC_EMBEDDINGS_KEY }}
docs-root-path: 'docs'

View File

@@ -1,88 +0,0 @@
name: Docker Compose Build Latest Tag (Manual Dispatch)
# The workflow is manually triggered
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Fetch all tags and set the latest tag
- name: Fetch tags and set the latest tag
run: |
git fetch --tags
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
# Set up Docker
- name: Set up Docker
uses: docker/setup-buildx-action@v3
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Prepare Docker Build
- name: Build Docker images
run: cp .env.example .env
# Docker metadata for librechat-api
- name: Docker metadata for librechat-api
id: meta-librechat-api
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/librechat-api
tags: |
type=raw,value=${{ env.LATEST_TAG }},enable=true
type=raw,value=latest,enable=true
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
# Build and push librechat-api
- name: Build and push librechat-api
uses: docker/build-push-action@v5
with:
file: Dockerfile.multi
context: .
push: true
tags: ${{ steps.meta-librechat-api.outputs.tags }}
platforms: linux/amd64,linux/arm64
target: api-build
# Docker metadata for librechat
- name: Docker metadata for librechat
id: meta-librechat
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/librechat
tags: |
type=raw,value=${{ env.LATEST_TAG }},enable=true
type=raw,value=latest,enable=true
type=semver,pattern={{version}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}}
# Build and push librechat
- name: Build and push librechat
uses: docker/build-push-action@v5
with:
file: Dockerfile
context: .
push: true
tags: ${{ steps.meta-librechat.outputs.tags }}
platforms: linux/amd64,linux/arm64
target: node

View File

@@ -0,0 +1,69 @@
name: Docker Compose Build Latest Main Image Tag (Manual Dispatch)
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: librechat-api
- target: node
file: Dockerfile
image_name: librechat
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch tags and set the latest tag
run: |
git fetch --tags
echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Prepare the environment
- name: Prepare environment
run: |
cp .env.example .env
# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}

67
.github/workflows/tag-images.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Docker Images Build on Tag
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: librechat-api
- target: node
file: Dockerfile
image_name: librechat
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Prepare the environment
- name: Prepare environment
run: |
cp .env.example .env
# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.ref_name }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}

7
.gitignore vendored
View File

@@ -50,6 +50,7 @@ bower_components/
#config file
librechat.yaml
librechat.yml
# Environment
.npmrc
@@ -74,6 +75,7 @@ src/style - official.css
config.local.ts
**/storageState.json
junit.xml
**/.venv/
# docker override file
docker-compose.override.yaml
@@ -91,4 +93,7 @@ auth.json
!client/src/components/Nav/SettingsTabs/Data/
# User uploads
uploads/
uploads/
# owner
release/

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
#!/usr/bin/env sh
set -e
. "$(dirname -- "$0")/_/husky.sh"
[ -n "$CI" ] && exit 0

View File

@@ -1,15 +1,26 @@
# Base node image
FROM node:18-alpine AS node
# v0.7.0
COPY . /app
# Base node image
FROM node:18-alpine3.18 AS node
RUN apk add g++ make py3-pip
RUN npm install -g node-gyp
RUN apk --no-cache add curl
RUN mkdir -p /app && chown node:node /app
WORKDIR /app
USER node
COPY --chown=node:node . .
# Allow mounting of these files, which have no default
# values.
RUN touch .env
# Install call deps - Install curl for health check
RUN apk --no-cache add curl && \
npm ci
RUN npm config set fetch-retry-maxtimeout 600000
RUN npm config set fetch-retries 5
RUN npm config set fetch-retry-mintimeout 15000
RUN npm install --no-audit
# React client build
ENV NODE_OPTIONS="--max-old-space-size=2048"

View File

@@ -1,3 +1,5 @@
# v0.7.0
# Build API, Client and Data Provider
FROM node:20-alpine AS base
@@ -24,6 +26,8 @@ FROM data-provider-build AS api-build
WORKDIR /app/api
COPY api/package*.json ./
COPY api/ ./
# Copy helper scripts
COPY config/ ./
# Copy data-provider to API's node_modules
RUN mkdir -p /app/api/node_modules/librechat-data-provider/
RUN cp -R /app/packages/data-provider/* /app/api/node_modules/librechat-data-provider/

View File

@@ -1,10 +1,10 @@
<p align="center">
<a href="https://docs.librechat.ai">
<a href="https://librechat.ai">
<img src="docs/assets/LibreChat.svg" height="256">
</a>
<a href="https://docs.librechat.ai">
<h1 align="center">LibreChat</h1>
</a>
<h1 align="center">
<a href="https://librechat.ai">LibreChat</a>
</h1>
</p>
<p align="center">
@@ -39,30 +39,36 @@
</p>
# 📃 Features
- 🖥️ UI matching ChatGPT, including Dark mode, Streaming, and 11-2023 updates
- 💬 Multimodal Chat:
- Upload and analyze images with GPT-4 and Gemini Vision 📸
- More filetypes and Assistants API integration in Active Development 🚧
- 🌎 Multilingual UI:
- English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro,
- Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands
- 🤖 AI model selection: OpenAI API, Azure, BingAI, ChatGPT, Google Vertex AI, Anthropic (Claude), Plugins
- 💾 Create, Save, & Share Custom Presets
- 🔄 Edit, Resubmit, and Continue messages with conversation branching
- 📤 Export conversations as screenshots, markdown, text, json.
- 🔍 Search all messages/conversations
- 🔌 Plugins, including web access, image generation with DALL-E-3 and more
- 👥 Multi-User, Secure Authentication with Moderation and Token spend tools
- ⚙️ Configure Proxy, Reverse Proxy, Docker, many Deployment options, and completely Open-Source
- 🖥️ UI matching ChatGPT, including Dark mode, Streaming, and latest updates
- 💬 Multimodal Chat:
- Upload and analyze images with Claude 3, GPT-4, and Gemini Vision 📸
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, & Google. 🗃️
- Advanced Agents with Files, Code Interpreter, Tools, and API Actions 🔦
- Available through the [OpenAI Assistants API](https://platform.openai.com/docs/assistants/overview) 🌤️
- Non-OpenAI Agents in Active Development 🚧
- 🌎 Multilingual UI:
- English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro,
- Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית
- 🤖 AI model selection: OpenAI, Azure OpenAI, BingAI, ChatGPT, Google Vertex AI, Anthropic (Claude), Plugins, Assistants API (including Azure Assistants)
- 💾 Create, Save, & Share Custom Presets
- 🔄 Edit, Resubmit, and Continue messages with conversation branching
- 📤 Export conversations as screenshots, markdown, text, json.
- 🔍 Search all messages/conversations
- 🔌 Plugins, including web access, image generation with DALL-E-3 and more
- 👥 Multi-User, Secure Authentication with Moderation and Token spend tools
- ⚙️ Configure Proxy, Reverse Proxy, Docker, & many Deployment options
- 📖 Completely Open-Source & Built in Public
- 🧑‍🤝‍🧑 Community-driven development, support, and feedback
[For a thorough review of our features, see our docs here](https://docs.librechat.ai/features/plugins/introduction.html) 📚
## 🪶 All-In-One AI Conversations with LibreChat
LibreChat brings together the future of assistant AIs with the revolutionary technology of OpenAI's ChatGPT. Celebrating the original styling, LibreChat gives you the ability to integrate multiple AI models. It also integrates and enhances original client features such as conversation and message search, prompt templates and plugins.
With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform.
<!-- https://github.com/danny-avila/LibreChat/assets/110412045/c1eb0c0f-41f6-4335-b982-84b278b53d59 -->
[![Watch the video](https://img.youtube.com/vi/pNIOs1ovsXw/maxresdefault.jpg)](https://youtu.be/pNIOs1ovsXw)
@@ -71,11 +77,13 @@ Click on the thumbnail to open the video☝
---
## 📚 Documentation
For more information on how to use our advanced features, install and configure our software, and access our guidelines and tutorials, please check out our documentation at [docs.librechat.ai](https://docs.librechat.ai)
---
## 📝 Changelog
## 📝 Changelog
Keep up with the latest updates by visiting the releases page - [Releases](https://github.com/danny-avila/LibreChat/releases)
**⚠️ [Breaking Changes](docs/general_info/breaking_changes.md)**
@@ -96,14 +104,15 @@ Please consult the breaking changes before updating.
---
## ✨ Contributions
Contributions, suggestions, bug reports and fixes are welcome!
For new features, components, or extensions, please open an issue and discuss before sending a PR.
For new features, components, or extensions, please open an issue and discuss before sending a PR.
---
💖 This project exists in its current state thanks to all the people who contribute
---
## 💖 This project exists in its current state thanks to all the people who contribute
<a href="https://github.com/danny-avila/LibreChat/graphs/contributors">
<img src="https://contrib.rocks/image?repo=danny-avila/LibreChat" />
</a>

View File

@@ -1,6 +1,19 @@
const Anthropic = require('@anthropic-ai/sdk');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const { getResponseSender, EModelEndpoint } = require('librechat-data-provider');
const {
getResponseSender,
EModelEndpoint,
validateVisionModel,
} = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const {
titleFunctionPrompt,
parseTitleFromPrompt,
truncateText,
formatMessage,
createContextHandlers,
} = require('./prompts');
const spendTokens = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
@@ -10,12 +23,20 @@ const AI_PROMPT = '\n\nAssistant:';
const tokenizersCache = {};
/** Helper function to introduce a delay before retrying */
function delayBeforeRetry(attempts, baseDelay = 1000) {
return new Promise((resolve) => setTimeout(resolve, baseDelay * attempts));
}
class AnthropicClient extends BaseClient {
constructor(apiKey, options = {}) {
super(apiKey, options);
this.apiKey = apiKey || process.env.ANTHROPIC_API_KEY;
this.userLabel = HUMAN_PROMPT;
this.assistantLabel = AI_PROMPT;
this.contextStrategy = options.contextStrategy
? options.contextStrategy.toLowerCase()
: 'discard';
this.setOptions(options);
}
@@ -47,6 +68,12 @@ class AnthropicClient extends BaseClient {
stop: modelOptions.stop, // no stop method for now
};
this.isClaude3 = this.modelOptions.model.includes('claude-3');
this.useMessages = this.isClaude3 || !!this.options.attachments;
this.defaultVisionModel = this.options.visionModel ?? 'claude-3-sonnet-20240229';
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
this.maxContextTokens =
getModelMaxTokens(this.modelOptions.model, EModelEndpoint.anthropic) ?? 100000;
this.maxResponseTokens = this.modelOptions.maxOutputTokens || 1500;
@@ -87,7 +114,12 @@ class AnthropicClient extends BaseClient {
return this;
}
/**
* Get the initialized Anthropic client.
* @returns {Anthropic} The Anthropic client instance.
*/
getClient() {
/** @type {Anthropic.default.RequestOptions} */
const options = {
apiKey: this.apiKey,
};
@@ -99,6 +131,75 @@ class AnthropicClient extends BaseClient {
return new Anthropic(options);
}
getTokenCountForResponse(response) {
return this.getTokenCountForMessage({
role: 'assistant',
content: response.text,
});
}
/**
*
* Checks if the model is a vision model based on request attachments and sets the appropriate options:
* - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request.
* - Sets `this.isVisionModel` to `true` if vision request.
* - Deletes `this.modelOptions.stop` if vision request.
* @param {MongoFile[]} attachments
*/
checkVisionRequest(attachments) {
const availableModels = this.options.modelsConfig?.[EModelEndpoint.anthropic];
this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
const visionModelAvailable = availableModels?.includes(this.defaultVisionModel);
if (
attachments &&
attachments.some((file) => file?.type && file?.type?.includes('image')) &&
visionModelAvailable &&
!this.isVisionModel
) {
this.modelOptions.model = this.defaultVisionModel;
this.isVisionModel = true;
}
}
/**
* Calculate the token cost in tokens for an image based on its dimensions and detail level.
*
* For reference, see: https://docs.anthropic.com/claude/docs/vision#image-costs
*
* @param {Object} image - The image object.
* @param {number} image.width - The width of the image.
* @param {number} image.height - The height of the image.
* @returns {number} The calculated token cost measured by tokens.
*
*/
calculateImageTokenCost({ width, height }) {
return Math.ceil((width * height) / 750);
}
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments,
EModelEndpoint.anthropic,
);
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}
async recordTokenUsage({ promptTokens, completionTokens, model, context = 'message' }) {
await spendTokens(
{
context,
user: this.user,
conversationId: this.conversationId,
model: model ?? this.modelOptions.model,
endpointTokenConfig: this.options.endpointTokenConfig,
},
{ promptTokens, completionTokens },
);
}
async buildMessages(messages, parentMessageId) {
const orderedMessages = this.constructor.getMessagesForConversation({
messages,
@@ -107,28 +208,145 @@ class AnthropicClient extends BaseClient {
logger.debug('[AnthropicClient] orderedMessages', { orderedMessages, parentMessageId });
const formattedMessages = orderedMessages.map((message) => ({
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
}));
if (this.options.attachments) {
const attachments = await this.options.attachments;
const images = attachments.filter((file) => file.type.includes('image'));
if (images.length && !this.isVisionModel) {
throw new Error('Images are only supported with the Claude 3 family of models');
}
const latestMessage = orderedMessages[orderedMessages.length - 1];
if (this.message_file_map) {
this.message_file_map[latestMessage.messageId] = attachments;
} else {
this.message_file_map = {
[latestMessage.messageId]: attachments,
};
}
const files = await this.addImageURLs(latestMessage, attachments);
this.options.attachments = files;
}
if (this.message_file_map) {
this.contextHandlers = createContextHandlers(
this.options.req,
orderedMessages[orderedMessages.length - 1].text,
);
}
const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = this.useMessages
? formatMessage({
message,
endpoint: EModelEndpoint.anthropic,
})
: {
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
};
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
orderedMessages[i].tokenCount = this.getTokenCountForMessage(formattedMessage);
}
/* If message has files, calculate image token cost */
if (this.message_file_map && this.message_file_map[message.messageId]) {
const attachments = this.message_file_map[message.messageId];
for (const file of attachments) {
if (file.embedded) {
this.contextHandlers?.processFile(file);
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,
height: file.height,
});
}
}
formattedMessage.tokenCount = orderedMessages[i].tokenCount;
return formattedMessage;
});
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
this.options.promptPrefix = this.augmentedPrompt + (this.options.promptPrefix ?? '');
}
let { context: messagesInWindow, remainingContextTokens } =
await this.getMessagesWithinTokenLimit(formattedMessages);
const tokenCountMap = orderedMessages
.slice(orderedMessages.length - messagesInWindow.length)
.reduce((map, message, index) => {
const { messageId } = message;
if (!messageId) {
return map;
}
map[messageId] = orderedMessages[index].tokenCount;
return map;
}, {});
logger.debug('[AnthropicClient]', {
messagesInWindow: messagesInWindow.length,
remainingContextTokens,
});
let lastAuthor = '';
let groupedMessages = [];
for (let message of formattedMessages) {
for (let i = 0; i < messagesInWindow.length; i++) {
const message = messagesInWindow[i];
const author = message.role ?? message.author;
// If last author is not same as current author, add to new group
if (lastAuthor !== message.author) {
groupedMessages.push({
author: message.author,
if (lastAuthor !== author) {
const newMessage = {
content: [message.content],
});
lastAuthor = message.author;
};
if (message.role) {
newMessage.role = message.role;
} else {
newMessage.author = message.author;
}
groupedMessages.push(newMessage);
lastAuthor = author;
// If same author, append content to the last group
} else {
groupedMessages[groupedMessages.length - 1].content.push(message.content);
}
}
groupedMessages = groupedMessages.map((msg, i) => {
const isLast = i === groupedMessages.length - 1;
if (msg.content.length === 1) {
const content = msg.content[0];
return {
...msg,
// reason: final assistant content cannot end with trailing whitespace
content:
isLast && this.useMessages && msg.role === 'assistant' && typeof content === 'string'
? content?.trim()
: content,
};
}
if (!this.useMessages && msg.tokenCount) {
delete msg.tokenCount;
}
return msg;
});
let identityPrefix = '';
if (this.options.userLabel) {
identityPrefix = `\nHuman's name: ${this.options.userLabel}`;
@@ -154,9 +372,10 @@ class AnthropicClient extends BaseClient {
// Prompt AI to respond, empty if last message was from AI
let isEdited = lastAuthor === this.assistantLabel;
const promptSuffix = isEdited ? '' : `${promptPrefix}${this.assistantLabel}\n`;
let currentTokenCount = isEdited
? this.getTokenCount(promptPrefix)
: this.getTokenCount(promptSuffix);
let currentTokenCount =
isEdited || this.useMessages
? this.getTokenCount(promptPrefix)
: this.getTokenCount(promptSuffix);
let promptBody = '';
const maxTokenCount = this.maxPromptTokens;
@@ -224,7 +443,69 @@ class AnthropicClient extends BaseClient {
return true;
};
await buildPromptBody();
const messagesPayload = [];
const buildMessagesPayload = async () => {
let canContinue = true;
if (promptPrefix) {
this.systemMessage = promptPrefix;
}
while (currentTokenCount < maxTokenCount && groupedMessages.length > 0 && canContinue) {
const message = groupedMessages.pop();
let tokenCountForMessage = message.tokenCount ?? this.getTokenCountForMessage(message);
const newTokenCount = currentTokenCount + tokenCountForMessage;
const exceededMaxCount = newTokenCount > maxTokenCount;
if (exceededMaxCount && messagesPayload.length === 0) {
throw new Error(
`Prompt is too long. Max token count is ${maxTokenCount}, but prompt is ${newTokenCount} tokens long.`,
);
} else if (exceededMaxCount) {
canContinue = false;
break;
}
delete message.tokenCount;
messagesPayload.unshift(message);
currentTokenCount = newTokenCount;
// Switch off isEdited after using it once
if (isEdited && message.role === 'assistant') {
isEdited = false;
}
// Wait for next tick to avoid blocking the event loop
await new Promise((resolve) => setImmediate(resolve));
}
};
const processTokens = () => {
// Add 2 tokens for metadata after all messages have been counted.
currentTokenCount += 2;
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
this.modelOptions.maxOutputTokens = Math.min(
this.maxContextTokens - currentTokenCount,
this.maxResponseTokens,
);
};
if (this.modelOptions.model.startsWith('claude-3')) {
await buildMessagesPayload();
processTokens();
return {
prompt: messagesPayload,
context: messagesInWindow,
promptTokens: currentTokenCount,
tokenCountMap,
};
} else {
await buildPromptBody();
processTokens();
}
if (nextMessage.remove) {
promptBody = promptBody.replace(nextMessage.messageString, '');
@@ -234,22 +515,26 @@ class AnthropicClient extends BaseClient {
let prompt = `${promptBody}${promptSuffix}`;
// Add 2 tokens for metadata after all messages have been counted.
currentTokenCount += 2;
// Use up to `this.maxContextTokens` tokens (prompt + response), but try to leave `this.maxTokens` tokens for the response.
this.modelOptions.maxOutputTokens = Math.min(
this.maxContextTokens - currentTokenCount,
this.maxResponseTokens,
);
return { prompt, context };
return { prompt, context, promptTokens: currentTokenCount, tokenCountMap };
}
getCompletion() {
logger.debug('AnthropicClient doesn\'t use getCompletion (all handled in sendCompletion)');
}
/**
* Creates a message or completion response using the Anthropic client.
* @param {Anthropic} client - The Anthropic client instance.
* @param {Anthropic.default.MessageCreateParams | Anthropic.default.CompletionCreateParams} options - The options for the message or completion.
* @param {boolean} useMessages - Whether to use messages or completions. Defaults to `this.useMessages`.
* @returns {Promise<Anthropic.default.Message | Anthropic.default.Completion>} The response from the Anthropic client.
*/
async createResponse(client, options, useMessages) {
return useMessages ?? this.useMessages
? await client.messages.create(options)
: await client.completions.create(options);
}
async sendCompletion(payload, { onProgress, abortController }) {
if (!abortController) {
abortController = new AbortController();
@@ -279,36 +564,88 @@ class AnthropicClient extends BaseClient {
topP: top_p,
topK: top_k,
} = this.modelOptions;
const requestOptions = {
prompt: payload,
model,
stream: stream || true,
max_tokens_to_sample: maxOutputTokens || 1500,
stop_sequences,
temperature,
metadata,
top_p,
top_k,
};
logger.debug('[AnthropicClient]', { ...requestOptions });
const response = await client.completions.create(requestOptions);
signal.addEventListener('abort', () => {
logger.debug('[AnthropicClient] message aborted!');
response.controller.abort();
});
for await (const completion of response) {
// Uncomment to debug message stream
// logger.debug(completion);
text += completion.completion;
onProgress(completion.completion);
if (this.useMessages) {
requestOptions.messages = payload;
requestOptions.max_tokens = maxOutputTokens || 1500;
} else {
requestOptions.prompt = payload;
requestOptions.max_tokens_to_sample = maxOutputTokens || 1500;
}
signal.removeEventListener('abort', () => {
logger.debug('[AnthropicClient] message aborted!');
response.controller.abort();
});
if (this.systemMessage) {
requestOptions.system = this.systemMessage;
}
logger.debug('[AnthropicClient]', { ...requestOptions });
const handleChunk = (currentChunk) => {
if (currentChunk) {
text += currentChunk;
onProgress(currentChunk);
}
};
const maxRetries = 3;
async function processResponse() {
let attempts = 0;
while (attempts < maxRetries) {
let response;
try {
response = await this.createResponse(client, requestOptions);
signal.addEventListener('abort', () => {
logger.debug('[AnthropicClient] message aborted!');
if (response.controller?.abort) {
response.controller.abort();
}
});
for await (const completion of response) {
// Handle each completion as before
if (completion?.delta?.text) {
handleChunk(completion.delta.text);
} else if (completion.completion) {
handleChunk(completion.completion);
}
}
// Successful processing, exit loop
break;
} catch (error) {
attempts += 1;
logger.warn(
`User: ${this.user} | Anthropic Request ${attempts} failed: ${error.message}`,
);
if (attempts < maxRetries) {
await delayBeforeRetry(attempts, 350);
} else {
throw new Error(`Operation failed after ${maxRetries} attempts: ${error.message}`);
}
} finally {
signal.removeEventListener('abort', () => {
logger.debug('[AnthropicClient] message aborted!');
if (response.controller?.abort) {
response.controller.abort();
}
});
}
}
}
await processResponse.bind(this)();
return text.trim();
}
@@ -317,6 +654,7 @@ class AnthropicClient extends BaseClient {
return {
promptPrefix: this.options.promptPrefix,
modelLabel: this.options.modelLabel,
resendFiles: this.options.resendFiles,
...this.modelOptions,
};
}
@@ -342,6 +680,78 @@ class AnthropicClient extends BaseClient {
getTokenCount(text) {
return this.gptEncoder.encode(text, 'all').length;
}
/**
* Generates a concise title for a conversation based on the user's input text and response.
* Involves sending a chat completion request with specific instructions for title generation.
*
* This function capitlizes on [Anthropic's function calling training](https://docs.anthropic.com/claude/docs/functions-external-tools).
*
* @param {Object} params - The parameters for the conversation title generation.
* @param {string} params.text - The user's input.
* @param {string} [params.responseText=''] - The AI's immediate response to the user.
*
* @returns {Promise<string | 'New Chat'>} A promise that resolves to the generated conversation title.
* In case of failure, it will return the default title, "New Chat".
*/
async titleConvo({ text, responseText = '' }) {
let title = 'New Chat';
const convo = `<initial_message>
${truncateText(text)}
</initial_message>
<response>
${JSON.stringify(truncateText(responseText))}
</response>`;
const { ANTHROPIC_TITLE_MODEL } = process.env ?? {};
const model = this.options.titleModel ?? ANTHROPIC_TITLE_MODEL ?? 'claude-3-haiku-20240307';
const system = titleFunctionPrompt;
const titleChatCompletion = async () => {
const content = `<conversation_context>
${convo}
</conversation_context>
Please generate a title for this conversation.`;
const titleMessage = { role: 'user', content };
const requestOptions = {
model,
temperature: 0.3,
max_tokens: 1024,
system,
stop_sequences: ['\n\nHuman:', '\n\nAssistant', '</function_calls>'],
messages: [titleMessage],
};
try {
const response = await this.createResponse(this.getClient(), requestOptions, true);
let promptTokens = response?.usage?.input_tokens;
let completionTokens = response?.usage?.output_tokens;
if (!promptTokens) {
promptTokens = this.getTokenCountForMessage(titleMessage);
promptTokens += this.getTokenCountForMessage({ role: 'system', content: system });
}
if (!completionTokens) {
completionTokens = this.getTokenCountForMessage(response.content[0]);
}
await this.recordTokenUsage({
model,
promptTokens,
completionTokens,
context: 'title',
});
const text = response.content[0].text;
title = parseTitleFromPrompt(text);
} catch (e) {
logger.error('[AnthropicClient] There was an issue generating the title', e);
}
};
await titleChatCompletion();
logger.debug('[AnthropicClient] Convo Title: ' + title);
return title;
}
}
module.exports = AnthropicClient;

View File

@@ -3,6 +3,7 @@ const { supportsBalanceCheck, Constants } = require('librechat-data-provider');
const { getConvo, getMessages, saveMessage, updateMessage, saveConvo } = require('~/models');
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
const checkBalance = require('~/models/checkBalance');
const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream');
const { logger } = require('~/config');
@@ -46,10 +47,6 @@ class BaseClient {
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', response);
}
async addPreviousAttachments(messages) {
return messages;
}
async recordTokenUsage({ promptTokens, completionTokens }) {
logger.debug('`[BaseClient] recordTokenUsage` not implemented.', {
promptTokens,
@@ -447,6 +444,8 @@ class BaseClient {
}
const completion = await this.sendCompletion(payload, opts);
this.abortController.requestCompleted = true;
const responseMessage = {
messageId: responseMessageId,
conversationId,
@@ -457,6 +456,7 @@ class BaseClient {
sender: this.sender,
text: addSpaceIfNeeded(generation) + completion,
promptTokens,
...(this.metadata ?? {}),
};
if (
@@ -681,6 +681,54 @@ class BaseClient {
return await this.sendCompletion(payload, opts);
}
/**
*
* @param {TMessage[]} _messages
* @returns {Promise<TMessage[]>}
*/
async addPreviousAttachments(_messages) {
if (!this.options.resendFiles) {
return _messages;
}
/**
*
* @param {TMessage} message
*/
const processMessage = async (message) => {
if (!this.message_file_map) {
/** @type {Record<string, MongoFile[]> */
this.message_file_map = {};
}
const fileIds = message.files.map((file) => file.file_id);
const files = await getFiles({
file_id: { $in: fileIds },
});
await this.addImageURLs(message, files);
this.message_file_map[message.messageId] = files;
return message;
};
const promises = [];
for (const message of _messages) {
if (!message.files) {
promises.push(message);
continue;
}
promises.push(processMessage(message));
}
const messages = await Promise.all(promises);
this.checkVisionRequest(Object.values(this.message_file_map ?? {}).flat());
return messages;
}
}
module.exports = BaseClient;

View File

@@ -1,9 +1,16 @@
const crypto = require('crypto');
const Keyv = require('keyv');
const crypto = require('crypto');
const {
EModelEndpoint,
resolveHeaders,
mapModelToAzureConfig,
} = require('librechat-data-provider');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const { fetchEventSource } = require('@waylaidwanderer/fetch-event-source');
const { Agent, ProxyAgent } = require('undici');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
const { extractBaseURL, constructAzureURL, genAzureChatCompletion } = require('~/utils');
const CHATGPT_MODEL = 'gpt-3.5-turbo';
const tokenizersCache = {};
@@ -144,7 +151,8 @@ class ChatGPTClient extends BaseClient {
if (!abortController) {
abortController = new AbortController();
}
const modelOptions = { ...this.modelOptions };
let modelOptions = { ...this.modelOptions };
if (typeof onProgress === 'function') {
modelOptions.stream = true;
}
@@ -159,56 +167,171 @@ class ChatGPTClient extends BaseClient {
}
const { debug } = this.options;
const url = this.completionsUrl;
let baseURL = this.completionsUrl;
if (debug) {
console.debug();
console.debug(url);
console.debug(baseURL);
console.debug(modelOptions);
console.debug();
}
if (this.azure || this.options.azure) {
// Azure does not accept `model` in the body, so we need to remove it.
delete modelOptions.model;
}
const opts = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(modelOptions),
dispatcher: new Agent({
bodyTimeout: 0,
headersTimeout: 0,
}),
};
if (this.apiKey && this.options.azure) {
opts.headers['api-key'] = this.apiKey;
if (this.isVisionModel) {
modelOptions.max_tokens = 4000;
}
/** @type {TAzureConfig | undefined} */
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
const isAzure = this.azure || this.options.azure;
if (
(isAzure && this.isVisionModel && azureConfig) ||
(azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI)
) {
const { modelGroupMap, groupMap } = azureConfig;
const {
azureOptions,
baseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName: modelOptions.model,
modelGroupMap,
groupMap,
});
opts.headers = resolveHeaders(headers);
this.langchainProxy = extractBaseURL(baseURL);
this.apiKey = azureOptions.azureOpenAIApiKey;
const groupName = modelGroupMap[modelOptions.model].group;
this.options.addParams = azureConfig.groupMap[groupName].addParams;
this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
// Note: `forcePrompt` not re-assigned as only chat models are vision models
this.azure = !serverless && azureOptions;
this.azureEndpoint =
!serverless && genAzureChatCompletion(this.azure, modelOptions.model, this);
}
if (this.options.headers) {
opts.headers = { ...opts.headers, ...this.options.headers };
}
if (isAzure) {
// Azure does not accept `model` in the body, so we need to remove it.
delete modelOptions.model;
baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
if (this.options.forcePrompt) {
baseURL += '/completions';
} else {
baseURL += '/chat/completions';
}
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
opts.headers = { ...opts.headers, 'api-key': this.apiKey };
} else if (this.apiKey) {
opts.headers.Authorization = `Bearer ${this.apiKey}`;
}
if (process.env.OPENAI_ORGANIZATION) {
opts.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
}
if (this.useOpenRouter) {
opts.headers['HTTP-Referer'] = 'https://librechat.ai';
opts.headers['X-Title'] = 'LibreChat';
}
if (this.options.headers) {
opts.headers = { ...opts.headers, ...this.options.headers };
}
if (this.options.proxy) {
opts.dispatcher = new ProxyAgent(this.options.proxy);
}
/* hacky fixes for Mistral AI API:
- Re-orders system message to the top of the messages payload, as not allowed anywhere else
- If there is only one message and it's a system message, change the role to user
*/
if (baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) {
const { messages } = modelOptions;
const systemMessageIndex = messages.findIndex((msg) => msg.role === 'system');
if (systemMessageIndex > 0) {
const [systemMessage] = messages.splice(systemMessageIndex, 1);
messages.unshift(systemMessage);
}
modelOptions.messages = messages;
if (messages.length === 1 && messages[0].role === 'system') {
modelOptions.messages[0].role = 'user';
}
}
if (this.options.addParams && typeof this.options.addParams === 'object') {
modelOptions = {
...modelOptions,
...this.options.addParams,
};
logger.debug('[ChatGPTClient] chatCompletion: added params', {
addParams: this.options.addParams,
modelOptions,
});
}
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
this.options.dropParams.forEach((param) => {
delete modelOptions[param];
});
logger.debug('[ChatGPTClient] chatCompletion: dropped params', {
dropParams: this.options.dropParams,
modelOptions,
});
}
if (baseURL.includes('v1') && !baseURL.includes('/completions') && !this.isChatCompletion) {
baseURL = baseURL.split('v1')[0] + 'v1/completions';
} else if (
baseURL.includes('v1') &&
!baseURL.includes('/chat/completions') &&
this.isChatCompletion
) {
baseURL = baseURL.split('v1')[0] + 'v1/chat/completions';
}
const BASE_URL = new URL(baseURL);
if (opts.defaultQuery) {
Object.entries(opts.defaultQuery).forEach(([key, value]) => {
BASE_URL.searchParams.append(key, value);
});
delete opts.defaultQuery;
}
const completionsURL = BASE_URL.toString();
opts.body = JSON.stringify(modelOptions);
if (modelOptions.stream) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
let done = false;
await fetchEventSource(url, {
await fetchEventSource(completionsURL, {
...opts,
signal: abortController.signal,
async onopen(response) {
@@ -236,7 +359,6 @@ class ChatGPTClient extends BaseClient {
// workaround for private API not sending [DONE] event
if (!done) {
onProgress('[DONE]');
abortController.abort();
resolve();
}
},
@@ -249,14 +371,13 @@ class ChatGPTClient extends BaseClient {
},
onmessage(message) {
if (debug) {
// console.debug(message);
console.debug(message);
}
if (!message.data || message.event === 'ping') {
return;
}
if (message.data === '[DONE]') {
onProgress('[DONE]');
abortController.abort();
resolve();
done = true;
return;
@@ -269,7 +390,7 @@ class ChatGPTClient extends BaseClient {
}
});
}
const response = await fetch(url, {
const response = await fetch(completionsURL, {
...opts,
signal: abortController.signal,
});

View File

@@ -4,7 +4,6 @@ const { GoogleVertexAI } = require('langchain/llms/googlevertexai');
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
const { ChatGoogleVertexAI } = require('langchain/chat_models/googlevertexai');
const { AIMessage, HumanMessage, SystemMessage } = require('langchain/schema');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const {
validateVisionModel,
@@ -13,8 +12,9 @@ const {
EModelEndpoint,
AuthKeys,
} = require('librechat-data-provider');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { formatMessage, createContextHandlers } = require('./prompts');
const { getModelMaxTokens } = require('~/utils');
const { formatMessage } = require('./prompts');
const BaseClient = require('./BaseClient');
const { logger } = require('~/config');
@@ -124,18 +124,11 @@ class GoogleClient extends BaseClient {
// stop: modelOptions.stop // no stop method for now
};
if (this.options.attachments) {
this.modelOptions.model = 'gemini-pro-vision';
}
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
// TODO: as of 12/14/23, only gemini models are "Generative AI" models provided by Google
this.isGenerativeModel = this.modelOptions.model.includes('gemini');
this.isVisionModel = validateVisionModel(this.modelOptions.model);
const { isGenerativeModel } = this;
if (this.isVisionModel && !this.options.attachments) {
this.modelOptions.model = 'gemini-pro';
this.isVisionModel = false;
}
this.isChatModel = !isGenerativeModel && this.modelOptions.model.includes('chat');
const { isChatModel } = this;
this.isTextModel =
@@ -220,6 +213,33 @@ class GoogleClient extends BaseClient {
return this;
}
/**
*
* Checks if the model is a vision model based on request attachments and sets the appropriate options:
* @param {MongoFile[]} attachments
*/
checkVisionRequest(attachments) {
/* Validation vision request */
this.defaultVisionModel = this.options.visionModel ?? 'gemini-pro-vision';
const availableModels = this.options.modelsConfig?.[EModelEndpoint.google];
this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
if (
attachments &&
attachments.some((file) => file?.type && file?.type?.includes('image')) &&
availableModels?.includes(this.defaultVisionModel) &&
!this.isVisionModel
) {
this.modelOptions.model = this.defaultVisionModel;
this.isVisionModel = true;
}
if (this.isVisionModel && !attachments) {
this.modelOptions.model = 'gemini-pro';
this.isVisionModel = false;
}
}
formatMessages() {
return ((message) => ({
author: message?.author ?? (message.isCreatedByUser ? this.userLabel : this.modelLabel),
@@ -227,18 +247,45 @@ class GoogleClient extends BaseClient {
})).bind(this);
}
async buildVisionMessages(messages = [], parentMessageId) {
const { prompt } = await this.buildMessagesPrompt(messages, parentMessageId);
const attachments = await this.options.attachments;
/**
*
* Adds image URLs to the message object and returns the files
*
* @param {TMessage[]} messages
* @param {MongoFile[]} files
* @returns {Promise<MongoFile[]>}
*/
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(
this.options.req,
attachments.filter((file) => file.type.includes('image')),
attachments,
EModelEndpoint.google,
);
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}
async buildVisionMessages(messages = [], parentMessageId) {
const attachments = await this.options.attachments;
const latestMessage = { ...messages[messages.length - 1] };
this.contextHandlers = createContextHandlers(this.options.req, latestMessage.text);
if (this.contextHandlers) {
for (const file of attachments) {
if (file.embedded) {
this.contextHandlers?.processFile(file);
continue;
}
}
this.augmentedPrompt = await this.contextHandlers.createContext();
this.options.promptPrefix = this.augmentedPrompt + this.options.promptPrefix;
}
const { prompt } = await this.buildMessagesPrompt(messages, parentMessageId);
const files = await this.addImageURLs(latestMessage, attachments);
latestMessage.image_urls = image_urls;
this.options.attachments = files;
latestMessage.text = prompt;
@@ -265,7 +312,7 @@ class GoogleClient extends BaseClient {
);
}
if (this.options.attachments) {
if (this.options.attachments && this.isGenerativeModel) {
return this.buildVisionMessages(messages, parentMessageId);
}

View File

@@ -1,10 +1,13 @@
const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent');
const {
ImageDetail,
EModelEndpoint,
resolveHeaders,
ImageDetailCost,
getResponseSender,
validateVisionModel,
ImageDetailCost,
ImageDetail,
mapModelToAzureConfig,
} = require('librechat-data-provider');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const {
@@ -13,14 +16,13 @@ const {
getModelMaxTokens,
genAzureChatCompletion,
} = require('~/utils');
const { truncateText, formatMessage, createContextHandlers, CUT_OFF_PROMPT } = require('./prompts');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { truncateText, formatMessage, CUT_OFF_PROMPT } = require('./prompts');
const { handleOpenAIErrors } = require('./tools/util');
const spendTokens = require('~/models/spendTokens');
const { createLLM, RunManager } = require('./llm');
const ChatGPTClient = require('./ChatGPTClient');
const { isEnabled } = require('~/server/utils');
const { getFiles } = require('~/models/File');
const { summaryBuffer } = require('./memory');
const { runTitleChain } = require('./chains');
const { tokenSplit } = require('./document');
@@ -45,6 +47,7 @@ class OpenAIClient extends BaseClient {
/** @type {AzureOptions} */
this.azure = options.azure || false;
this.setOptions(options);
this.metadata = {};
}
// TODO: PluginsClient calls this 3x, unneeded
@@ -88,7 +91,12 @@ class OpenAIClient extends BaseClient {
};
}
this.checkVisionRequest(this.options.attachments);
this.defaultVisionModel = this.options.visionModel ?? 'gpt-4-vision-preview';
if (typeof this.options.attachments?.then === 'function') {
this.options.attachments.then((attachments) => this.checkVisionRequest(attachments));
} else {
this.checkVisionRequest(this.options.attachments);
}
const { OPENROUTER_API_KEY, OPENAI_FORCE_PROMPT } = process.env ?? {};
if (OPENROUTER_API_KEY && !this.azure) {
@@ -219,13 +227,20 @@ class OpenAIClient extends BaseClient {
* - Sets `this.modelOptions.model` to `gpt-4-vision-preview` if the request is a vision request.
* - Sets `this.isVisionModel` to `true` if vision request.
* - Deletes `this.modelOptions.stop` if vision request.
* @param {Array<Promise<MongoFile[]> | MongoFile[]> | Record<string, MongoFile[]>} attachments
* @param {MongoFile[]} attachments
*/
checkVisionRequest(attachments) {
this.isVisionModel = validateVisionModel(this.modelOptions.model);
const availableModels = this.options.modelsConfig?.[this.options.endpoint];
this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
if (attachments && !this.isVisionModel) {
this.modelOptions.model = 'gpt-4-vision-preview';
const visionModelAvailable = availableModels?.includes(this.defaultVisionModel);
if (
attachments &&
attachments.some((file) => file?.type && file?.type?.includes('image')) &&
visionModelAvailable &&
!this.isVisionModel
) {
this.modelOptions.model = this.defaultVisionModel;
this.isVisionModel = true;
}
@@ -360,7 +375,7 @@ class OpenAIClient extends BaseClient {
return {
chatGptLabel: this.options.chatGptLabel,
promptPrefix: this.options.promptPrefix,
resendImages: this.options.resendImages,
resendFiles: this.options.resendFiles,
imageDetail: this.options.imageDetail,
...this.modelOptions,
};
@@ -374,54 +389,6 @@ class OpenAIClient extends BaseClient {
};
}
/**
*
* @param {TMessage[]} _messages
* @returns {TMessage[]}
*/
async addPreviousAttachments(_messages) {
if (!this.options.resendImages) {
return _messages;
}
/**
*
* @param {TMessage} message
*/
const processMessage = async (message) => {
if (!this.message_file_map) {
/** @type {Record<string, MongoFile[]> */
this.message_file_map = {};
}
const fileIds = message.files.map((file) => file.file_id);
const files = await getFiles({
file_id: { $in: fileIds },
});
await this.addImageURLs(message, files);
this.message_file_map[message.messageId] = files;
return message;
};
const promises = [];
for (const message of _messages) {
if (!message.files) {
promises.push(message);
continue;
}
promises.push(processMessage(message));
}
const messages = await Promise.all(promises);
this.checkVisionRequest(this.message_file_map);
return messages;
}
/**
*
* Adds image URLs to the message object and returns the files
@@ -432,8 +399,7 @@ class OpenAIClient extends BaseClient {
*/
async addImageURLs(message, attachments) {
const { files, image_urls } = await encodeAndFormat(this.options.req, attachments);
message.image_urls = image_urls;
message.image_urls = image_urls.length ? image_urls : undefined;
return files;
}
@@ -461,23 +427,9 @@ class OpenAIClient extends BaseClient {
let promptTokens;
promptPrefix = (promptPrefix || this.options.promptPrefix || '').trim();
if (promptPrefix) {
promptPrefix = `Instructions:\n${promptPrefix}`;
instructions = {
role: 'system',
name: 'instructions',
content: promptPrefix,
};
if (this.contextStrategy) {
instructions.tokenCount = this.getTokenCountForMessage(instructions);
}
}
if (this.options.attachments) {
const attachments = (await this.options.attachments).filter((file) =>
file.type.includes('image'),
);
const attachments = await this.options.attachments;
if (this.message_file_map) {
this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments;
@@ -495,6 +447,13 @@ class OpenAIClient extends BaseClient {
this.options.attachments = files;
}
if (this.message_file_map) {
this.contextHandlers = createContextHandlers(
this.options.req,
orderedMessages[orderedMessages.length - 1].text,
);
}
const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = formatMessage({
message,
@@ -513,6 +472,11 @@ class OpenAIClient extends BaseClient {
if (this.message_file_map && this.message_file_map[message.messageId]) {
const attachments = this.message_file_map[message.messageId];
for (const file of attachments) {
if (file.embedded) {
this.contextHandlers?.processFile(file);
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,
height: file.height,
@@ -524,6 +488,24 @@ class OpenAIClient extends BaseClient {
return formattedMessage;
});
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
promptPrefix = this.augmentedPrompt + promptPrefix;
}
if (promptPrefix) {
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
instructions = {
role: 'system',
name: 'instructions',
content: promptPrefix,
};
if (this.contextStrategy) {
instructions.tokenCount = this.getTokenCountForMessage(instructions);
}
}
// TODO: need to handle interleaving instructions better
if (this.contextStrategy) {
({ payload, tokenCountMap, promptTokens, messages } = await this.handleContextStrategy({
@@ -557,7 +539,7 @@ class OpenAIClient extends BaseClient {
let streamResult = null;
this.modelOptions.user = this.user;
const invalidBaseUrl = this.completionsUrl && extractBaseURL(this.completionsUrl) === null;
const useOldMethod = !!(invalidBaseUrl || !this.isChatCompletion);
const useOldMethod = !!(invalidBaseUrl || !this.isChatCompletion || typeof Bun !== 'undefined');
if (typeof opts.onProgress === 'function' && useOldMethod) {
await this.getCompletion(
payload,
@@ -597,7 +579,6 @@ class OpenAIClient extends BaseClient {
} else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) {
reply = await this.chatCompletion({
payload,
clientOptions: opts,
onProgress: opts.onProgress,
abortController: opts.abortController,
});
@@ -617,11 +598,11 @@ class OpenAIClient extends BaseClient {
}
}
if (streamResult && typeof opts.addMetadata === 'function') {
if (streamResult) {
const { finish_reason } = streamResult.choices[0];
opts.addMetadata({ finish_reason });
this.metadata = { finish_reason };
}
return reply.trim();
return (reply ?? '').trim();
}
initializeLLM({
@@ -665,6 +646,16 @@ class OpenAIClient extends BaseClient {
};
}
const { headers } = this.options;
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
configOptions.baseOptions = {
headers: resolveHeaders({
...headers,
...configOptions?.baseOptions?.headers,
}),
};
}
if (this.options.proxy) {
configOptions.httpAgent = new HttpsProxyAgent(this.options.proxy);
configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy);
@@ -725,6 +716,39 @@ class OpenAIClient extends BaseClient {
max_tokens: 16,
};
/** @type {TAzureConfig | undefined} */
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
const resetTitleOptions = !!(
(this.azure && azureConfig) ||
(azureConfig && this.options.endpoint === EModelEndpoint.azureOpenAI)
);
if (resetTitleOptions) {
const { modelGroupMap, groupMap } = azureConfig;
const {
azureOptions,
baseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName: modelOptions.model,
modelGroupMap,
groupMap,
});
this.options.headers = resolveHeaders(headers);
this.options.reverseProxyUrl = baseURL ?? null;
this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl);
this.apiKey = azureOptions.azureOpenAIApiKey;
const groupName = modelGroupMap[modelOptions.model].group;
this.options.addParams = azureConfig.groupMap[groupName].addParams;
this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
this.options.forcePrompt = azureConfig.groupMap[groupName].forcePrompt;
this.azure = !serverless && azureOptions;
}
const titleChatCompletion = async () => {
modelOptions.model = model;
@@ -901,7 +925,6 @@ ${convo}
}
async recordTokenUsage({ promptTokens, completionTokens }) {
logger.debug('[OpenAIClient] recordTokenUsage:', { promptTokens, completionTokens });
await spendTokens(
{
user: this.user,
@@ -921,7 +944,7 @@ ${convo}
});
}
async chatCompletion({ payload, onProgress, clientOptions, abortController = null }) {
async chatCompletion({ payload, onProgress, abortController = null }) {
let error = null;
const errorCallback = (err) => (error = err);
let intermediateReply = '';
@@ -942,15 +965,6 @@ ${convo}
}
const baseURL = extractBaseURL(this.completionsUrl);
// let { messages: _msgsToLog, ...modelOptionsToLog } = modelOptions;
// if (modelOptionsToLog.messages) {
// _msgsToLog = modelOptionsToLog.messages.map((msg) => {
// let { content, ...rest } = msg;
// if (content)
// return { ...rest, content: truncateText(content) };
// });
// }
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
const opts = {
baseURL,
@@ -975,6 +989,38 @@ ${convo}
modelOptions.max_tokens = 4000;
}
/** @type {TAzureConfig | undefined} */
const azureConfig = this.options?.req?.app?.locals?.[EModelEndpoint.azureOpenAI];
if (
(this.azure && this.isVisionModel && azureConfig) ||
(azureConfig && this.isVisionModel && this.options.endpoint === EModelEndpoint.azureOpenAI)
) {
const { modelGroupMap, groupMap } = azureConfig;
const {
azureOptions,
baseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName: modelOptions.model,
modelGroupMap,
groupMap,
});
opts.defaultHeaders = resolveHeaders(headers);
this.langchainProxy = extractBaseURL(baseURL);
this.apiKey = azureOptions.azureOpenAIApiKey;
const groupName = modelGroupMap[modelOptions.model].group;
this.options.addParams = azureConfig.groupMap[groupName].addParams;
this.options.dropParams = azureConfig.groupMap[groupName].dropParams;
// Note: `forcePrompt` not re-assigned as only chat models are vision models
this.azure = !serverless && azureOptions;
this.azureEndpoint =
!serverless && genAzureChatCompletion(this.azure, modelOptions.model, this);
}
if (this.azure || this.options.azure) {
// Azure does not accept `model` in the body, so we need to remove it.
delete modelOptions.model;
@@ -982,9 +1028,10 @@ ${convo}
opts.baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azure: this.azure,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/\/(chat|completion)/)[0];
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
opts.defaultHeaders = { ...opts.defaultHeaders, 'api-key': this.apiKey };
}
@@ -994,6 +1041,7 @@ ${convo}
}
let chatCompletion;
/** @type {OpenAI} */
const openai = new OpenAI({
apiKey: this.apiKey,
...opts,
@@ -1025,12 +1073,20 @@ ${convo}
...modelOptions,
...this.options.addParams,
};
logger.debug('[OpenAIClient] chatCompletion: added params', {
addParams: this.options.addParams,
modelOptions,
});
}
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
this.options.dropParams.forEach((param) => {
delete modelOptions[param];
});
logger.debug('[OpenAIClient] chatCompletion: dropped params', {
dropParams: this.options.dropParams,
modelOptions,
});
}
let UnexpectedRoleError = false;
@@ -1046,6 +1102,16 @@ ${convo}
.on('error', (err) => {
handleOpenAIErrors(err, errorCallback, 'stream');
})
.on('finalChatCompletion', (finalChatCompletion) => {
const finalMessage = finalChatCompletion?.choices?.[0]?.message;
if (finalMessage && finalMessage?.role !== 'assistant') {
finalChatCompletion.choices[0].message.role = 'assistant';
}
if (finalMessage && !finalMessage?.content?.trim()) {
finalChatCompletion.choices[0].message.content = intermediateReply;
}
})
.on('finalMessage', (message) => {
if (message?.role !== 'assistant') {
stream.messages.push({ role: 'assistant', content: intermediateReply });
@@ -1091,12 +1157,20 @@ ${convo}
}
const { message, finish_reason } = chatCompletion.choices[0];
if (chatCompletion && typeof clientOptions.addMetadata === 'function') {
clientOptions.addMetadata({ finish_reason });
if (chatCompletion) {
this.metadata = { finish_reason };
}
logger.debug('[OpenAIClient] chatCompletion response', chatCompletion);
if (!message?.content?.trim() && intermediateReply.length) {
logger.debug(
'[OpenAIClient] chatCompletion: using intermediateReply due to empty message.content',
{ intermediateReply },
);
return intermediateReply;
}
return message.content;
} catch (err) {
if (
@@ -1109,6 +1183,9 @@ ${convo}
err?.message?.includes(
'OpenAI error: Invalid final message: OpenAI expects final message to include role=assistant',
) ||
err?.message?.includes(
'stream ended without producing a ChatCompletionMessage with role=assistant',
) ||
err?.message?.includes('The server had an error processing your request') ||
err?.message?.includes('missing finish_reason') ||
err?.message?.includes('missing role') ||

View File

@@ -31,10 +31,6 @@ class PluginsClient extends OpenAIClient {
super.setOptions(options);
if (this.functionsAgent && this.agentOptions.model && !this.useOpenRouter && !this.azure) {
this.agentOptions.model = this.getFunctionModelName(this.agentOptions.model);
}
this.isGpt3 = this.modelOptions?.model?.includes('gpt-3');
if (this.options.reverseProxyUrl) {

View File

@@ -55,16 +55,18 @@ function createLLM({
}
if (azure && configOptions.basePath) {
configOptions.basePath = constructAzureURL({
const azureURL = constructAzureURL({
baseURL: configOptions.basePath,
azure: azureOptions,
azureOptions,
});
azureOptions.azureOpenAIBasePath = azureURL.split(
`/${azureOptions.azureOpenAIApiDeploymentName}`,
)[0];
}
return new ChatOpenAI(
{
streaming,
verbose: true,
credentials,
configuration,
...azureOptions,

View File

@@ -0,0 +1,159 @@
const axios = require('axios');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const footer = `Use the context as your learned knowledge to better answer the user.
In your response, remember to follow these guidelines:
- If you don't know the answer, simply say that you don't know.
- If you are unsure how to answer, ask for clarification.
- Avoid mentioning that you obtained the information from the context.
Answer appropriately in the user's language.
`;
function createContextHandlers(req, userMessageContent) {
if (!process.env.RAG_API_URL) {
return;
}
const queryPromises = [];
const processedFiles = [];
const processedIds = new Set();
const jwtToken = req.headers.authorization.split(' ')[1];
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
const query = async (file) => {
if (useFullContext) {
return axios.get(`${process.env.RAG_API_URL}/documents/${file.file_id}/context`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
},
});
}
return axios.post(
`${process.env.RAG_API_URL}/query`,
{
file_id: file.file_id,
query: userMessageContent,
k: 4,
},
{
headers: {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json',
},
},
);
};
const processFile = async (file) => {
if (file.embedded && !processedIds.has(file.file_id)) {
try {
const promise = query(file);
queryPromises.push(promise);
processedFiles.push(file);
processedIds.add(file.file_id);
} catch (error) {
logger.error(`Error processing file ${file.filename}:`, error);
}
}
};
const createContext = async () => {
try {
if (!queryPromises.length || !processedFiles.length) {
return '';
}
const oneFile = processedFiles.length === 1;
const header = `The user has attached ${oneFile ? 'a' : processedFiles.length} file${
!oneFile ? 's' : ''
} to the conversation:`;
const files = `${
oneFile
? ''
: `
<files>`
}${processedFiles
.map(
(file) => `
<file>
<filename>${file.filename}</filename>
<type>${file.type}</type>
</file>`,
)
.join('')}${
oneFile
? ''
: `
</files>`
}`;
const resolvedQueries = await Promise.all(queryPromises);
const context = resolvedQueries
.map((queryResult, index) => {
const file = processedFiles[index];
let contextItems = queryResult.data;
const generateContext = (currentContext) =>
`
<file>
<filename>${file.filename}</filename>
<context>${currentContext}
</context>
</file>`;
if (useFullContext) {
return generateContext(`\n${contextItems}`);
}
contextItems = queryResult.data
.map((item) => {
const pageContent = item[0].page_content;
return `
<contextItem>
<![CDATA[${pageContent?.trim()}]]>
</contextItem>`;
})
.join('');
return generateContext(contextItems);
})
.join('');
if (useFullContext) {
const prompt = `${header}
${context}
${footer}`;
return prompt;
}
const prompt = `${header}
${files}
A semantic search was executed with the user's message as the query, retrieving the following context inside <context></context> XML tags.
<context>${context}
</context>
${footer}`;
return prompt;
} catch (error) {
logger.error('Error creating context:', error);
throw error;
}
};
return {
processFile,
createContext,
};
}
module.exports = createContextHandlers;

View File

@@ -0,0 +1,34 @@
/**
* Generates a prompt instructing the user to describe an image in detail, tailored to different types of visual content.
* @param {boolean} pluralized - Whether to pluralize the prompt for multiple images.
* @returns {string} - The generated vision prompt.
*/
const createVisionPrompt = (pluralized = false) => {
return `Please describe the image${
pluralized ? 's' : ''
} in detail, covering relevant aspects such as:
For photographs, illustrations, or artwork:
- The main subject(s) and their appearance, positioning, and actions
- The setting, background, and any notable objects or elements
- Colors, lighting, and overall mood or atmosphere
- Any interesting details, textures, or patterns
- The style, technique, or medium used (if discernible)
For screenshots or images containing text:
- The content and purpose of the text
- The layout, formatting, and organization of the information
- Any notable visual elements, such as logos, icons, or graphics
- The overall context or message conveyed by the screenshot
For graphs, charts, or data visualizations:
- The type of graph or chart (e.g., bar graph, line chart, pie chart)
- The variables being compared or analyzed
- Any trends, patterns, or outliers in the data
- The axis labels, scales, and units of measurement
- The title, legend, and any additional context provided
Be as specific and descriptive as possible while maintaining clarity and concision.`;
};
module.exports = createVisionPrompt;

View File

@@ -1,3 +1,4 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { HumanMessage, AIMessage, SystemMessage } = require('langchain/schema');
/**
@@ -7,10 +8,16 @@ const { HumanMessage, AIMessage, SystemMessage } = require('langchain/schema');
* @param {Object} params.message - The message object to format.
* @param {string} [params.message.role] - The role of the message sender (must be 'user').
* @param {string} [params.message.content] - The text content of the message.
* @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
* @param {Array<string>} [params.image_urls] - The image_urls to attach to the message.
* @returns {(Object)} - The formatted message.
*/
const formatVisionMessage = ({ message, image_urls }) => {
const formatVisionMessage = ({ message, image_urls, endpoint }) => {
if (endpoint === EModelEndpoint.anthropic) {
message.content = [...image_urls, { type: 'text', text: message.content }];
return message;
}
message.content = [{ type: 'text', text: message.content }, ...image_urls];
return message;
@@ -29,10 +36,11 @@ const formatVisionMessage = ({ message, image_urls }) => {
* @param {Array<string>} [params.message.image_urls] - The image_urls attached to the message for Vision API.
* @param {string} [params.userName] - The name of the user.
* @param {string} [params.assistantName] - The name of the assistant.
* @param {string} [params.endpoint] - Identifier for specific endpoint handling
* @param {boolean} [params.langChain=false] - Whether to return a LangChain message object.
* @returns {(Object|HumanMessage|AIMessage|SystemMessage)} - The formatted message.
*/
const formatMessage = ({ message, userName, assistantName, langChain = false }) => {
const formatMessage = ({ message, userName, assistantName, endpoint, langChain = false }) => {
let { role: _role, _name, sender, text, content: _content, lc_id } = message;
if (lc_id && lc_id[2] && !langChain) {
const roleMapping = {
@@ -51,7 +59,11 @@ const formatMessage = ({ message, userName, assistantName, langChain = false })
const { image_urls } = message;
if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
return formatVisionMessage({ message: formattedMessage, image_urls: message.image_urls });
return formatVisionMessage({
message: formattedMessage,
image_urls: message.image_urls,
endpoint,
});
}
if (_name) {

View File

@@ -4,6 +4,8 @@ const handleInputs = require('./handleInputs');
const instructions = require('./instructions');
const titlePrompts = require('./titlePrompts');
const truncateText = require('./truncateText');
const createVisionPrompt = require('./createVisionPrompt');
const createContextHandlers = require('./createContextHandlers');
module.exports = {
...formatMessages,
@@ -12,4 +14,6 @@ module.exports = {
...instructions,
...titlePrompts,
truncateText,
createVisionPrompt,
createContextHandlers,
};

View File

@@ -27,7 +27,60 @@ ${convo}`,
return titlePrompt;
};
const titleFunctionPrompt = `In this environment you have access to a set of tools you can use to generate the conversation title.
You may call them like this:
<function_calls>
<invoke>
<tool_name>$TOOL_NAME</tool_name>
<parameters>
<$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME>
...
</parameters>
</invoke>
</function_calls>
Here are the tools available:
<tools>
<tool_description>
<tool_name>submit_title</tool_name>
<description>
Submit a brief title in the conversation's language, following the parameter description closely.
</description>
<parameters>
<parameter>
<name>title</name>
<type>string</type>
<description>A concise, 5-word-or-less title for the conversation, using its same language, with no punctuation. Apply title case conventions appropriate for the language. For English, use AP Stylebook Title Case. Never directly mention the language name or the word "title"</description>
</parameter>
</parameters>
</tool_description>
</tools>`;
/**
* Parses titles from title functions based on the provided prompt.
* @param {string} prompt - The prompt containing the title function.
* @returns {string} The parsed title. "New Chat" if no title is found.
*/
function parseTitleFromPrompt(prompt) {
const titleRegex = /<title>(.+?)<\/title>/;
const titleMatch = prompt.match(titleRegex);
if (titleMatch && titleMatch[1]) {
const title = titleMatch[1].trim();
// // Capitalize the first letter of each word; Note: unnecessary due to title case prompting
// const capitalizedTitle = title.replace(/\b\w/g, (char) => char.toUpperCase());
return title;
}
return 'New Chat';
}
module.exports = {
langPrompt,
createTitlePrompt,
titleFunctionPrompt,
parseTitleFromPrompt,
};

View File

@@ -1,121 +0,0 @@
const { google } = require('googleapis');
const { Tool } = require('langchain/tools');
const { logger } = require('~/config');
/**
* Represents a tool that allows an agent to use the Google Custom Search API.
* @extends Tool
*/
class GoogleSearchAPI extends Tool {
constructor(fields = {}) {
super();
this.cx = fields.GOOGLE_CSE_ID || this.getCx();
this.apiKey = fields.GOOGLE_API_KEY || this.getApiKey();
this.customSearch = undefined;
}
/**
* The name of the tool.
* @type {string}
*/
name = 'google';
/**
* A description for the agent to use
* @type {string}
*/
description =
'Use the \'google\' tool to retrieve internet search results relevant to your input. The results will return links and snippets of text from the webpages';
description_for_model =
'Use the \'google\' tool to retrieve internet search results relevant to your input. The results will return links and snippets of text from the webpages';
getCx() {
const cx = process.env.GOOGLE_CSE_ID || '';
if (!cx) {
throw new Error('Missing GOOGLE_CSE_ID environment variable.');
}
return cx;
}
getApiKey() {
const apiKey = process.env.GOOGLE_API_KEY || '';
if (!apiKey) {
throw new Error('Missing GOOGLE_API_KEY environment variable.');
}
return apiKey;
}
getCustomSearch() {
if (!this.customSearch) {
const version = 'v1';
this.customSearch = google.customsearch(version);
}
return this.customSearch;
}
resultsToReadableFormat(results) {
let output = 'Results:\n';
results.forEach((resultObj, index) => {
output += `Title: ${resultObj.title}\n`;
output += `Link: ${resultObj.link}\n`;
if (resultObj.snippet) {
output += `Snippet: ${resultObj.snippet}\n`;
}
if (index < results.length - 1) {
output += '\n';
}
});
return output;
}
/**
* Calls the tool with the provided input and returns a promise that resolves with a response from the Google Custom Search API.
* @param {string} input - The input to provide to the API.
* @returns {Promise<String>} A promise that resolves with a response from the Google Custom Search API.
*/
async _call(input) {
try {
const metadataResults = [];
const response = await this.getCustomSearch().cse.list({
q: input,
cx: this.cx,
auth: this.apiKey,
num: 5, // Limit the number of results to 5
});
// return response.data;
// logger.debug(response.data);
if (!response.data.items || response.data.items.length === 0) {
return this.resultsToReadableFormat([
{ title: 'No good Google Search Result was found', link: '' },
]);
}
// const results = response.items.slice(0, numResults);
const results = response.data.items;
for (const result of results) {
const metadataResult = {
title: result.title || '',
link: result.link || '',
};
if (result.snippet) {
metadataResult.snippet = result.snippet;
}
metadataResults.push(metadataResult);
}
return this.resultsToReadableFormat(metadataResults);
} catch (error) {
logger.error('[GoogleSearchAPI]', error);
// throw error;
return 'There was an error searching Google.';
}
}
}
module.exports = GoogleSearchAPI;

View File

@@ -1,7 +1,6 @@
const availableTools = require('./manifest.json');
// Basic Tools
const CodeBrew = require('./CodeBrew');
const GoogleSearchAPI = require('./GoogleSearch');
const WolframAlphaAPI = require('./Wolfram');
const AzureAiSearch = require('./AzureAiSearch');
const OpenAICreateImage = require('./DALL-E');
@@ -16,8 +15,10 @@ const CodeSherpa = require('./structured/CodeSherpa');
const StructuredSD = require('./structured/StableDiffusion');
const StructuredACS = require('./structured/AzureAISearch');
const CodeSherpaTools = require('./structured/CodeSherpaTools');
const GoogleSearchAPI = require('./structured/GoogleSearch');
const StructuredWolfram = require('./structured/Wolfram');
const TavilySearchResults = require('./structured/TavilySearchResults');
const TraversaalSearch = require('./structured/TraversaalSearch');
module.exports = {
availableTools,
@@ -39,4 +40,5 @@ module.exports = {
CodeSherpaTools,
StructuredWolfram,
TavilySearchResults,
TraversaalSearch,
};

View File

@@ -1,4 +1,17 @@
[
{
"name": "Traversaal",
"pluginKey": "traversaal_search",
"description": "Traversaal is a robust search API tailored for LLM Agents. Get an API key here: https://api.traversaal.ai",
"icon": "https://traversaal.ai/favicon.ico",
"authConfig": [
{
"authField": "TRAVERSAAL_API_KEY",
"label": "Traversaal API Key",
"description": "Get your API key here: <a href=\"https://api.traversaal.ai\" target=\"_blank\">https://api.traversaal.ai</a>"
}
]
},
{
"name": "Google",
"pluginKey": "google",
@@ -111,7 +124,7 @@
{
"name": "Tavily Search",
"pluginKey": "tavily_search_results_json",
"description": "Tavily Search is a robust search API tailored specifically for LLM Agents. It seamlessly integrates with diverse data sources to ensure a superior, relevant search experience.",
"description": "Tavily Search is a robust search API tailored for LLM Agents. It seamlessly integrates with diverse data sources to ensure a superior, relevant search experience.",
"icon": "https://tavily.com/favicon.ico",
"authConfig": [
{

View File

@@ -12,14 +12,15 @@ const { logger } = require('~/config');
class DALLE3 extends Tool {
constructor(fields = {}) {
super();
/* Used to initialize the Tool without necessary variables. */
/** @type {boolean} Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;
/* Necessary for output to contain all image metadata. */
/** @type {boolean} Necessary for output to contain all image metadata. */
this.returnMetadata = fields.returnMetadata ?? false;
this.userId = fields.userId;
this.fileStrategy = fields.fileStrategy;
if (fields.processFileURL) {
/** @type {processFileURL} Necessary for output to contain all image metadata. */
this.processFileURL = fields.processFileURL.bind(this);
}
@@ -43,6 +44,7 @@ class DALLE3 extends Tool {
config.httpAgent = new HttpsProxyAgent(process.env.PROXY);
}
/** @type {OpenAI} */
this.openai = new OpenAI(config);
this.name = 'dalle';
this.description = `Use DALLE to create images from text descriptions.
@@ -164,13 +166,7 @@ Error Message: ${error.message}`;
});
if (this.returnMetadata) {
this.result = {
file_id: result.file_id,
filename: result.filename,
filepath: result.filepath,
height: result.height,
width: result.width,
};
this.result = result;
} else {
this.result = this.wrapInMarkdown(result.filepath);
}

View File

@@ -0,0 +1,65 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
class GoogleSearchResults extends Tool {
static lc_name() {
return 'GoogleSearchResults';
}
constructor(fields = {}) {
super(fields);
this.envVarApiKey = 'GOOGLE_API_KEY';
this.envVarSearchEngineId = 'GOOGLE_CSE_ID';
this.override = fields.override ?? false;
this.apiKey = fields.apiKey ?? getEnvironmentVariable(this.envVarApiKey);
this.searchEngineId =
fields.searchEngineId ?? getEnvironmentVariable(this.envVarSearchEngineId);
this.kwargs = fields?.kwargs ?? {};
this.name = 'google';
this.description =
'A search engine optimized for comprehensive, accurate, and trusted results. Useful for when you need to answer questions about current events.';
this.schema = z.object({
query: z.string().min(1).describe('The search query string.'),
max_results: z
.number()
.min(1)
.max(10)
.optional()
.describe('The maximum number of search results to return. Defaults to 10.'),
// Note: Google API has its own parameters for search customization, adjust as needed.
});
}
async _call(input) {
const validationResult = this.schema.safeParse(input);
if (!validationResult.success) {
throw new Error(`Validation failed: ${JSON.stringify(validationResult.error.issues)}`);
}
const { query, max_results = 5 } = validationResult.data;
const response = await fetch(
`https://www.googleapis.com/customsearch/v1?key=${this.apiKey}&cx=${
this.searchEngineId
}&q=${encodeURIComponent(query)}&num=${max_results}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
);
const json = await response.json();
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}: ${json.error.message}`);
}
return JSON.stringify(json);
}
}
module.exports = GoogleSearchResults;

View File

@@ -4,14 +4,27 @@ const { z } = require('zod');
const path = require('path');
const axios = require('axios');
const sharp = require('sharp');
const { v4: uuidv4 } = require('uuid');
const { StructuredTool } = require('langchain/tools');
const { FileContext } = require('librechat-data-provider');
const paths = require('~/config/paths');
const { logger } = require('~/config');
class StableDiffusionAPI extends StructuredTool {
constructor(fields) {
super();
/* Used to initialize the Tool without necessary variables. */
/** @type {string} User ID */
this.userId = fields.userId;
/** @type {Express.Request | undefined} Express Request object, only provided by ToolService */
this.req = fields.req;
/** @type {boolean} Used to initialize the Tool without necessary variables. */
this.override = fields.override ?? false;
/** @type {boolean} Necessary for output to contain all image metadata. */
this.returnMetadata = fields.returnMetadata ?? false;
if (fields.uploadImageBuffer) {
/** @type {uploadImageBuffer} Necessary for output to contain all image metadata. */
this.uploadImageBuffer = fields.uploadImageBuffer.bind(this);
}
this.name = 'stable-diffusion';
this.url = fields.SD_WEBUI_URL || this.getServerURL();
@@ -47,7 +60,7 @@ class StableDiffusionAPI extends StructuredTool {
getMarkdownImageUrl(imageName) {
const imageUrl = path
.join(this.relativeImageUrl, imageName)
.join(this.relativePath, this.userId, imageName)
.replace(/\\/g, '/')
.replace('public/', '');
return `![generated image](/${imageUrl})`;
@@ -73,46 +86,67 @@ class StableDiffusionAPI extends StructuredTool {
width: 1024,
height: 1024,
};
const response = await axios.post(`${url}/sdapi/v1/txt2img`, payload);
const image = response.data.images[0];
const pngPayload = { image: `data:image/png;base64,${image}` };
const response2 = await axios.post(`${url}/sdapi/v1/png-info`, pngPayload);
const info = response2.data.info;
const generationResponse = await axios.post(`${url}/sdapi/v1/txt2img`, payload);
const image = generationResponse.data.images[0];
// Generate unique name
const imageName = `${Date.now()}.png`;
this.outputPath = path.resolve(
__dirname,
'..',
'..',
'..',
'..',
'..',
'client',
'public',
'images',
);
const appRoot = path.resolve(__dirname, '..', '..', '..', '..', '..', 'client');
this.relativeImageUrl = path.relative(appRoot, this.outputPath);
/** @type {{ height: number, width: number, seed: number, infotexts: string[] }} */
let info = {};
try {
info = JSON.parse(generationResponse.data.info);
} catch (error) {
logger.error('[StableDiffusion] Error while getting image metadata:', error);
}
// Check if directory exists, if not create it
if (!fs.existsSync(this.outputPath)) {
fs.mkdirSync(this.outputPath, { recursive: true });
const file_id = uuidv4();
const imageName = `${file_id}.png`;
const { imageOutput: imageOutputPath, clientPath } = paths;
const filepath = path.join(imageOutputPath, this.userId, imageName);
this.relativePath = path.relative(clientPath, imageOutputPath);
if (!fs.existsSync(path.join(imageOutputPath, this.userId))) {
fs.mkdirSync(path.join(imageOutputPath, this.userId), { recursive: true });
}
try {
const buffer = Buffer.from(image.split(',', 1)[0], 'base64');
if (this.returnMetadata && this.uploadImageBuffer && this.req) {
const file = await this.uploadImageBuffer({
req: this.req,
context: FileContext.image_generation,
resize: false,
metadata: {
buffer,
height: info.height,
width: info.width,
bytes: Buffer.byteLength(buffer),
filename: imageName,
type: 'image/png',
file_id,
},
});
const generationInfo = info.infotexts[0].split('\n').pop();
return {
...file,
prompt,
metadata: {
negative_prompt,
seed: info.seed,
info: generationInfo,
},
};
}
await sharp(buffer)
.withMetadata({
iptcpng: {
parameters: info,
parameters: info.infotexts[0],
},
})
.toFile(this.outputPath + '/' + imageName);
.toFile(filepath);
this.result = this.getMarkdownImageUrl(imageName);
} catch (error) {
logger.error('[StableDiffusion] Error while saving the image:', error);
// this.result = theImageUrl;
}
return this.result;

View File

@@ -0,0 +1,89 @@
const { z } = require('zod');
const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
const { logger } = require('~/config');
/**
* Tool for the Traversaal AI search API, Ares.
*/
class TraversaalSearch extends Tool {
static lc_name() {
return 'TraversaalSearch';
}
constructor(fields) {
super(fields);
this.name = 'traversaal_search';
this.description = `An AI search engine optimized for comprehensive, accurate, and trusted results.
Useful for when you need to answer questions about current events. Input should be a search query.`;
this.description_for_model =
'\'Please create a specific sentence for the AI to understand and use as a query to search the web based on the user\'s request. For example, "Find information about the highest mountains in the world." or "Show me the latest news articles about climate change and its impact on polar ice caps."\'';
this.schema = z.object({
query: z
.string()
.describe(
'A properly written sentence to be interpreted by an AI to search the web according to the user\'s request.',
),
});
this.apiKey = fields?.TRAVERSAAL_API_KEY ?? this.getApiKey();
}
getApiKey() {
const apiKey = getEnvironmentVariable('TRAVERSAAL_API_KEY');
if (!apiKey && this.override) {
throw new Error(
'No Traversaal API key found. Either set an environment variable named "TRAVERSAAL_API_KEY" or pass an API key as "apiKey".',
);
}
return apiKey;
}
// eslint-disable-next-line no-unused-vars
async _call({ query }, _runManager) {
const body = {
query: [query],
};
try {
const response = await fetch('https://api-ares.traversaal.ai/live/predict', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify({ ...body }),
});
const json = await response.json();
if (!response.ok) {
throw new Error(
`Request failed with status code ${response.status}: ${json.error ?? json.message}`,
);
}
if (!json.data) {
throw new Error('Could not parse Traversaal API results. Please try again.');
}
const baseText = json.data?.response_text ?? '';
const sources = json.data?.web_url;
const noResponse = 'No response found in Traversaal API results';
if (!baseText && !sources) {
return noResponse;
}
const sourcesText = sources?.length ? '\n\nSources:\n - ' + sources.join('\n - ') : '';
const result = baseText + sourcesText;
if (!result) {
return noResponse;
}
return result;
} catch (error) {
logger.error('Traversaal API request failed', error);
return `Traversaal API request failed: ${error.message}`;
}
}
}
module.exports = TraversaalSearch;

View File

@@ -20,6 +20,7 @@ const {
StructuredSD,
StructuredACS,
CodeSherpaTools,
TraversaalSearch,
StructuredWolfram,
TavilySearchResults,
} = require('../');
@@ -165,6 +166,7 @@ const loadTools = async ({
'stable-diffusion': functions ? StructuredSD : StableDiffusionAPI,
'azure-ai-search': functions ? StructuredACS : AzureAISearch,
CodeBrew: CodeBrew,
traversaal_search: TraversaalSearch,
};
const openAIApiKey = await getOpenAIKey(options, user);
@@ -235,9 +237,11 @@ const loadTools = async ({
}
const imageGenOptions = {
req: options.req,
fileStrategy: options.fileStrategy,
processFileURL: options.processFileURL,
returnMetadata: options.returnMetadata,
uploadImageBuffer: options.uploadImageBuffer,
};
const toolOptions = {

View File

@@ -1,5 +1,6 @@
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { availableTools } = require('../');
const { logger } = require('~/config');
/**
* Loads a suite of tools with authentication values for a given user, supporting alternate authentication fields.
@@ -30,7 +31,7 @@ const loadToolSuite = async ({ pluginKey, tools, user, options = {} }) => {
return value;
}
} catch (err) {
console.error(`Error fetching plugin auth value for ${field}: ${err.message}`);
logger.error(`Error fetching plugin auth value for ${field}: ${err.message}`);
}
}
return null;
@@ -41,7 +42,7 @@ const loadToolSuite = async ({ pluginKey, tools, user, options = {} }) => {
if (authValue !== null) {
authValues[auth.authField] = authValue;
} else {
console.warn(`No auth value found for ${auth.authField}`);
logger.warn(`[loadToolSuite] No auth value found for ${auth.authField}`);
}
}

View File

@@ -1,5 +1,5 @@
const Keyv = require('keyv');
const { CacheKeys } = require('librechat-data-provider');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { math, isEnabled } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
@@ -37,7 +37,7 @@ const modelQueries = isEnabled(process.env.USE_REDIS)
const abortKeys = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.ABORT_KEYS });
: new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: 600000 });
const namespaces = {
[CacheKeys.CONFIG_STORE]: config,
@@ -47,9 +47,12 @@ const namespaces = {
concurrent: createViolationInstance('concurrent'),
non_browser: createViolationInstance('non_browser'),
message_limit: createViolationInstance('message_limit'),
token_balance: createViolationInstance('token_balance'),
token_balance: createViolationInstance(ViolationTypes.TOKEN_BALANCE),
registrations: createViolationInstance('registrations'),
[CacheKeys.FILE_UPLOAD_LIMIT]: createViolationInstance(CacheKeys.FILE_UPLOAD_LIMIT),
[ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT),
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance(
ViolationTypes.ILLEGAL_MODEL_REQUEST,
),
logins: createViolationInstance('logins'),
[CacheKeys.ABORT_KEYS]: abortKeys,
[CacheKeys.TOKEN_CONFIG]: tokenConfig,

View File

@@ -1,7 +1,9 @@
const path = require('path');
module.exports = {
root: path.resolve(__dirname, '..', '..'),
uploads: path.resolve(__dirname, '..', '..', 'uploads'),
clientPath: path.resolve(__dirname, '..', '..', 'client'),
dist: path.resolve(__dirname, '..', '..', 'client', 'dist'),
publicPath: path.resolve(__dirname, '..', '..', 'client', 'public'),
imageOutput: path.resolve(__dirname, '..', '..', 'client', 'public', 'images'),

View File

@@ -5,7 +5,15 @@ const { redactFormat, redactMessage, debugTraverse } = require('./parsers');
const logDir = path.join(__dirname, '..', 'logs');
const { NODE_ENV, DEBUG_LOGGING = true, DEBUG_CONSOLE = false } = process.env;
const { NODE_ENV, DEBUG_LOGGING = true, DEBUG_CONSOLE = false, CONSOLE_JSON = false } = process.env;
const useConsoleJson =
(typeof CONSOLE_JSON === 'string' && CONSOLE_JSON?.toLowerCase() === 'true') ||
CONSOLE_JSON === true;
const useDebugConsole =
(typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE?.toLowerCase() === 'true') ||
DEBUG_CONSOLE === true;
const levels = {
error: 0,
@@ -33,7 +41,7 @@ const level = () => {
const fileFormat = winston.format.combine(
redactFormat(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.timestamp({ format: () => new Date().toISOString() }),
winston.format.errors({ stack: true }),
winston.format.splat(),
// redactErrors(),
@@ -99,14 +107,20 @@ const consoleFormat = winston.format.combine(
}),
);
if (
(typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE?.toLowerCase() === 'true') ||
DEBUG_CONSOLE === true
) {
if (useDebugConsole) {
transports.push(
new winston.transports.Console({
level: 'debug',
format: winston.format.combine(consoleFormat, debugTraverse),
format: useConsoleJson
? winston.format.combine(fileFormat, debugTraverse, winston.format.json())
: winston.format.combine(fileFormat, debugTraverse),
}),
);
} else if (useConsoleJson) {
transports.push(
new winston.transports.Console({
level: 'info',
format: winston.format.combine(fileFormat, winston.format.json()),
}),
);
} else {

View File

@@ -69,7 +69,7 @@ const updateFileUsage = async (data) => {
const { file_id, inc = 1 } = data;
const updateOperation = {
$inc: { usage: inc },
$unset: { expiresAt: '' },
$unset: { expiresAt: '', temp_file_id: '' },
};
return await File.findOneAndUpdate({ file_id }, updateOperation, { new: true }).lean();
};

View File

@@ -2,6 +2,7 @@ const mongoose = require('mongoose');
const { isEnabled } = require('../server/utils/handleText');
const transactionSchema = require('./schema/transaction');
const { getMultiplier } = require('./tx');
const { logger } = require('~/config');
const Balance = require('./Balance');
const cancelRate = 1.15;
@@ -36,11 +37,37 @@ transactionSchema.statics.create = async function (transactionData) {
}
// Adjust the user's balance
return await Balance.findOneAndUpdate(
const updatedBalance = await Balance.findOneAndUpdate(
{ user: transaction.user },
{ $inc: { tokenCredits: transaction.tokenValue } },
{ upsert: true, new: true },
).lean();
return {
rate: transaction.rate,
user: transaction.user.toString(),
balance: updatedBalance.tokenCredits,
[transaction.tokenType]: transaction.tokenValue,
};
};
module.exports = mongoose.model('Transaction', transactionSchema);
const Transaction = mongoose.model('Transaction', transactionSchema);
/**
* Queries and retrieves transactions based on a given filter.
* @async
* @function getTransactions
* @param {Object} filter - MongoDB filter object to apply when querying transactions.
* @returns {Promise<Array>} A promise that resolves to an array of matched transactions.
* @throws {Error} Throws an error if querying the database fails.
*/
async function getTransactions(filter) {
try {
return await Transaction.find(filter).lean();
} catch (error) {
logger.error('Error querying transactions:', error);
throw error;
}
}
module.exports = { Transaction, getTransactions };

View File

@@ -1,5 +1,6 @@
const { ViolationTypes } = require('librechat-data-provider');
const { logViolation } = require('~/cache');
const Balance = require('./Balance');
const { logViolation } = require('../cache');
/**
* Checks the balance for a user and determines if they can spend a certain amount.
* If the user cannot spend the amount, it logs a violation and denies the request.
@@ -25,7 +26,7 @@ const checkBalance = async ({ req, res, txData }) => {
return true;
}
const type = 'token_balance';
const type = ViolationTypes.TOKEN_BALANCE;
const errorMessage = {
type,
balance,

View File

@@ -22,14 +22,12 @@ const Key = require('./Key');
const User = require('./User');
const Session = require('./Session');
const Balance = require('./Balance');
const Transaction = require('./Transaction');
module.exports = {
User,
Key,
Session,
Balance,
Transaction,
hashPassword,
updateUser,

View File

@@ -45,7 +45,6 @@ const actionSchema = new Schema({
auth: AuthSchema,
domain: {
type: String,
unique: true,
required: true,
},
// json_schema: Schema.Types.Mixed,

View File

@@ -9,7 +9,6 @@ const assistantSchema = mongoose.Schema(
},
assistant_id: {
type: String,
unique: true,
index: true,
required: true,
},

View File

@@ -70,10 +70,14 @@ const conversationPreset = {
type: String,
},
file_ids: { type: [{ type: String }], default: undefined },
// vision
// deprecated
resendImages: {
type: Boolean,
},
// files
resendFiles: {
type: Boolean,
},
imageDetail: {
type: String,
},

View File

@@ -15,6 +15,9 @@ const mongoose = require('mongoose');
* @property {'file'} object - Type of object, always 'file'
* @property {string} type - Type of file
* @property {number} usage - Number of uses of the file
* @property {string} [context] - Context of the file origin
* @property {boolean} [embedded] - 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
* @property {number} [width] - Optional width of the file
* @property {number} [height] - Optional height of the file
@@ -61,6 +64,9 @@ const fileSchema = mongoose.Schema(
required: true,
default: 'file',
},
embedded: {
type: Boolean,
},
type: {
type: String,
required: true,
@@ -78,6 +84,9 @@ const fileSchema = mongoose.Schema(
type: String,
default: FileSources.local,
},
model: {
type: String,
},
width: Number,
height: Number,
expiresAt: {

View File

@@ -1,4 +1,4 @@
const Transaction = require('./Transaction');
const { Transaction } = require('./Transaction');
const { logger } = require('~/config');
/**
@@ -21,10 +21,15 @@ const { logger } = require('~/config');
*/
const spendTokens = async (txData, tokenUsage) => {
const { promptTokens, completionTokens } = tokenUsage;
logger.debug(`[spendTokens] conversationId: ${txData.conversationId} | Token usage: `, {
promptTokens,
completionTokens,
});
logger.debug(
`[spendTokens] conversationId: ${txData.conversationId}${
txData?.context ? ` | Context: ${txData?.context}` : ''
} | Token usage: `,
{
promptTokens,
completionTokens,
},
);
let prompt, completion;
try {
if (promptTokens >= 0) {
@@ -49,8 +54,12 @@ const spendTokens = async (txData, tokenUsage) => {
prompt &&
completion &&
logger.debug('[spendTokens] Transaction data record against balance:', {
prompt,
completion,
user: prompt.user,
prompt: prompt.prompt,
promptRate: prompt.rate,
completion: completion.completion,
completionRate: completion.rate,
balance: completion.balance,
});
} catch (err) {
logger.error('[spendTokens]', err);

View File

@@ -13,6 +13,12 @@ const tokenValues = {
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
'gpt-4-1106': { prompt: 10, completion: 30 },
'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 },
'claude-3-opus': { prompt: 15, completion: 75 },
'claude-3-sonnet': { prompt: 3, completion: 15 },
'claude-3-haiku': { prompt: 0.25, completion: 1.25 },
'claude-2.1': { prompt: 8, completion: 24 },
'claude-2': { prompt: 8, completion: 24 },
'claude-': { prompt: 0.8, completion: 2.4 },
};
/**
@@ -46,6 +52,8 @@ const getValueKey = (model, endpoint) => {
return '32k';
} else if (modelName.includes('gpt-4')) {
return '8k';
} else if (tokenValues[modelName]) {
return modelName;
}
return undefined;

View File

@@ -1,13 +1,19 @@
{
"name": "@librechat/backend",
"version": "0.6.10",
"version": "0.7.0",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
"server-dev": "echo 'please run this from the root directory'",
"test": "cross-env NODE_ENV=test jest",
"b:test": "NODE_ENV=test bun jest",
"test:ci": "jest --ci"
"test:ci": "jest --ci",
"add-balance": "node ./add-balance.js",
"list-balances": "node ./list-balances.js",
"user-stats": "node ./user-stats.js",
"create-user": "node ./create-user.js",
"ban-user": "node ./ban-user.js",
"delete-user": "node ./delete-user.js"
},
"repository": {
"type": "git",
@@ -25,9 +31,9 @@
"bugs": {
"url": "https://github.com/danny-avila/LibreChat/issues"
},
"homepage": "https://github.com/danny-avila/LibreChat#readme",
"homepage": "https://librechat.ai",
"dependencies": {
"@anthropic-ai/sdk": "^0.5.4",
"@anthropic-ai/sdk": "^0.16.1",
"@azure/search-documents": "^12.0.0",
"@keyv/mongo": "^2.1.8",
"@keyv/redis": "^2.8.1",
@@ -46,7 +52,7 @@
"express-rate-limit": "^6.9.0",
"express-session": "^1.17.3",
"file-type": "^18.7.0",
"firebase": "^10.6.0",
"firebase": "^10.8.0",
"googleapis": "^126.0.1",
"handlebars": "^4.7.7",
"html": "^1.0.0",
@@ -59,14 +65,14 @@
"langchain": "^0.0.214",
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"meilisearch": "^0.33.0",
"meilisearch": "^0.38.0",
"mime": "^3.0.0",
"module-alias": "^2.2.3",
"mongoose": "^7.1.1",
"multer": "^1.4.5-lts.1",
"nodejs-gpt": "^1.37.4",
"nodemailer": "^6.9.4",
"openai": "^4.20.1",
"openai": "^4.29.0",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"passport": "^0.6.0",

View File

@@ -1,7 +1,8 @@
const throttle = require('lodash/throttle');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
const { createAbortController, handleAbortError } = require('~/server/middleware');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage, getConvo } = require('~/models');
const { logger } = require('~/config');
const AskController = async (req, res, next, initializeClient, addTitle) => {
@@ -16,13 +17,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
logger.debug('[AskController]', { text, conversationId, ...endpointOption });
let metadata;
let userMessage;
let promptTokens;
let userMessageId;
let responseMessageId;
let lastSavedTimestamp = 0;
let saveDelay = 100;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
@@ -31,8 +29,6 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
const newConvo = !conversationId;
const user = req.user.id;
const addMetadata = (data) => (metadata = data);
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
@@ -54,11 +50,8 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
const { client } = await initializeClient({ req, res, endpointOption });
const { onProgress: progressCallback, getPartialText } = createOnProgress({
onProgress: ({ text: partialText }) => {
const currentTimestamp = Date.now();
if (currentTimestamp - lastSavedTimestamp > saveDelay) {
lastSavedTimestamp = currentTimestamp;
onProgress: throttle(
({ text: partialText }) => {
saveMessage({
messageId: responseMessageId,
sender,
@@ -70,12 +63,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
error: false,
user,
});
}
if (saveDelay < 500) {
saveDelay = 500;
}
},
},
3000,
{ trailing: false },
),
});
getText = getPartialText;
@@ -92,6 +83,20 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
const { abortController, onStart } = createAbortController(req, res, getAbortData);
res.on('close', () => {
logger.debug('[AskController] Request closed');
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AskController] Request aborted on close');
});
const messageOptions = {
user,
parentMessageId,
@@ -99,7 +104,6 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
overrideParentMessageId,
getReqData,
onStart,
addMetadata,
abortController,
onProgress: progressCallback.call(null, {
res,
@@ -114,22 +118,23 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
response.parentMessageId = overrideParentMessageId;
}
if (metadata) {
response = { ...response, ...metadata };
}
response.endpoint = endpointOption.endpoint;
const conversation = await getConvo(user, conversationId);
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (client.options.attachments) {
userMessage.files = client.options.attachments;
conversation.model = endpointOption.modelOptions.model;
delete userMessage.image_urls;
}
if (!abortController.signal.aborted) {
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: response,
});

View File

@@ -1,7 +1,8 @@
const throttle = require('lodash/throttle');
const { getResponseSender } = require('librechat-data-provider');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
const { createAbortController, handleAbortError } = require('~/server/middleware');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage, getConvo } = require('~/models');
const { logger } = require('~/config');
const EditController = async (req, res, next, initializeClient) => {
@@ -25,11 +26,8 @@ const EditController = async (req, res, next, initializeClient) => {
...endpointOption,
});
let metadata;
let userMessage;
let promptTokens;
let lastSavedTimestamp = 0;
let saveDelay = 100;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
@@ -38,7 +36,6 @@ const EditController = async (req, res, next, initializeClient) => {
const userMessageId = parentMessageId;
const user = req.user.id;
const addMetadata = (data) => (metadata = data);
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
@@ -53,11 +50,8 @@ const EditController = async (req, res, next, initializeClient) => {
const { onProgress: progressCallback, getPartialText } = createOnProgress({
generation,
onProgress: ({ text: partialText }) => {
const currentTimestamp = Date.now();
if (currentTimestamp - lastSavedTimestamp > saveDelay) {
lastSavedTimestamp = currentTimestamp;
onProgress: throttle(
({ text: partialText }) => {
saveMessage({
messageId: responseMessageId,
sender,
@@ -70,12 +64,10 @@ const EditController = async (req, res, next, initializeClient) => {
error: false,
user,
});
}
if (saveDelay < 500) {
saveDelay = 500;
}
},
},
3000,
{ trailing: false },
),
});
const getAbortData = () => ({
@@ -90,6 +82,20 @@ const EditController = async (req, res, next, initializeClient) => {
const { abortController, onStart } = createAbortController(req, res, getAbortData);
res.on('close', () => {
logger.debug('[EditController] Request closed');
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[EditController] Request aborted on close');
});
try {
const { client } = await initializeClient({ req, res, endpointOption });
@@ -104,7 +110,6 @@ const EditController = async (req, res, next, initializeClient) => {
overrideParentMessageId,
getReqData,
onStart,
addMetadata,
abortController,
onProgress: progressCallback.call(null, {
res,
@@ -113,15 +118,19 @@ const EditController = async (req, res, next, initializeClient) => {
}),
});
if (metadata) {
response = { ...response, ...metadata };
const conversation = await getConvo(user, conversationId);
conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
if (client.options.attachments) {
conversation.model = endpointOption.modelOptions.model;
}
if (!abortController.signal.aborted) {
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
conversation,
title: conversation.title,
requestMessage: userMessage,
responseMessage: response,
});

View File

@@ -1,4 +1,4 @@
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { CacheKeys, EModelEndpoint, orderEndpointsConfig } = require('librechat-data-provider');
const { loadDefaultEndpointsConfig, loadConfigEndpoints } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
@@ -10,15 +10,24 @@ async function endpointController(req, res) {
return;
}
const defaultEndpointsConfig = await loadDefaultEndpointsConfig();
const customConfigEndpoints = await loadConfigEndpoints();
const defaultEndpointsConfig = await loadDefaultEndpointsConfig(req);
const customConfigEndpoints = await loadConfigEndpoints(req);
const endpointsConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints };
if (endpointsConfig[EModelEndpoint.assistants] && req.app.locals?.[EModelEndpoint.assistants]) {
endpointsConfig[EModelEndpoint.assistants].disableBuilder =
req.app.locals[EModelEndpoint.assistants].disableBuilder;
/** @type {TEndpointsConfig} */
const mergedConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints };
if (mergedConfig[EModelEndpoint.assistants] && req.app.locals?.[EModelEndpoint.assistants]) {
const { disableBuilder, retrievalModels, capabilities, ..._rest } =
req.app.locals[EModelEndpoint.assistants];
mergedConfig[EModelEndpoint.assistants] = {
...mergedConfig[EModelEndpoint.assistants],
retrievalModels,
disableBuilder,
capabilities,
};
}
const endpointsConfig = orderEndpointsConfig(mergedConfig);
await cache.set(CacheKeys.ENDPOINT_CONFIG, endpointsConfig);
res.send(JSON.stringify(endpointsConfig));
}

View File

@@ -2,12 +2,26 @@ const { CacheKeys } = require('librechat-data-provider');
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
async function modelController(req, res) {
const getModelsConfig = async (req) => {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
let modelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (!modelsConfig) {
modelsConfig = await loadModels(req);
}
return modelsConfig;
};
/**
* Loads the models from the config.
* @param {Express.Request} req - The Express request object.
* @returns {Promise<TModelsConfig>} The models config.
*/
async function loadModels(req) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedModelsConfig = await cache.get(CacheKeys.MODELS_CONFIG);
if (cachedModelsConfig) {
res.send(cachedModelsConfig);
return;
return cachedModelsConfig;
}
const defaultModelsConfig = await loadDefaultModels(req);
const customModelsConfig = await loadConfigModels(req);
@@ -15,7 +29,12 @@ async function modelController(req, res) {
const modelConfig = { ...defaultModelsConfig, ...customModelsConfig };
await cache.set(CacheKeys.MODELS_CONFIG, modelConfig);
return modelConfig;
}
async function modelController(req, res) {
const modelConfig = await loadModels(req);
res.send(modelConfig);
}
module.exports = modelController;
module.exports = { modelController, loadModels, getModelsConfig };

View File

@@ -2,6 +2,7 @@ require('dotenv').config();
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
@@ -22,6 +23,9 @@ const port = Number(PORT) || 3080;
const host = HOST || 'localhost';
const startServer = async () => {
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
await connectDb();
logger.info('Connected to MongoDB');
await indexSync();

View File

@@ -110,7 +110,7 @@ const handleAbortError = async (res, req, error, data) => {
}
const respondWithError = async (partialText) => {
const options = {
let options = {
sender,
messageId,
conversationId,
@@ -121,7 +121,8 @@ const handleAbortError = async (res, req, error, data) => {
};
if (partialText) {
options.overrideProps = {
options = {
...options,
error: false,
unfinished: true,
text: partialText,

View File

@@ -1,16 +1,22 @@
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistant');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
// const spendTokens = require('~/models/spendTokens');
const { logger } = require('~/config');
const three_minutes = 1000 * 60 * 3;
async function abortRun(req, res) {
res.setHeader('Content-Type', 'application/json');
const { abortKey } = req.body;
const [conversationId, latestMessageId] = abortKey.split(':');
const conversation = await getConvo(req.user.id, conversationId);
if (conversation?.model) {
req.body.model = conversation.model;
}
if (!isUUID.safeParse(conversationId).success) {
logger.error('[abortRun] Invalid conversationId', { conversationId });
@@ -35,9 +41,9 @@ async function abortRun(req, res) {
const { openai } = await initializeClient({ req, res });
try {
await cache.set(cacheKey, 'cancelled');
await cache.set(cacheKey, 'cancelled', three_minutes);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('Cancelled run:', cancelledRun);
logger.debug('[abortRun] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[abortRun] Error cancelling run', error);
if (
@@ -71,7 +77,7 @@ async function abortRun(req, res) {
const finalEvent = {
title: 'New Chat',
final: true,
conversation: await getConvo(req.user.id, conversationId),
conversation,
runMessages,
};

View File

@@ -1,11 +1,12 @@
const { parseConvo, EModelEndpoint } = require('librechat-data-provider');
const { processFiles } = require('~/server/services/Files/process');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const assistants = require('~/server/services/Endpoints/assistants');
const gptPlugins = require('~/server/services/Endpoints/gptPlugins');
const { processFiles } = require('~/server/services/Files/process');
const anthropic = require('~/server/services/Endpoints/anthropic');
const openAI = require('~/server/services/Endpoints/openAI');
const custom = require('~/server/services/Endpoints/custom');
const google = require('~/server/services/Endpoints/google');
const assistant = require('~/server/services/Endpoints/assistant');
const buildFunction = {
[EModelEndpoint.openAI]: openAI.buildOptions,
@@ -14,10 +15,10 @@ const buildFunction = {
[EModelEndpoint.azureOpenAI]: openAI.buildOptions,
[EModelEndpoint.anthropic]: anthropic.buildOptions,
[EModelEndpoint.gptPlugins]: gptPlugins.buildOptions,
[EModelEndpoint.assistants]: assistant.buildOptions,
[EModelEndpoint.assistants]: assistants.buildOptions,
};
function buildEndpointOption(req, res, next) {
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body });
req.body.endpointOption = buildFunction[endpointType ?? endpoint](
@@ -25,6 +26,10 @@ function buildEndpointOption(req, res, next) {
parsedBody,
endpointType,
);
const modelsConfig = await getModelsConfig(req);
req.body.endpointOption.modelsConfig = modelsConfig;
if (req.body.files) {
// hold the promise
req.body.endpointOption.attachments = processFiles(req.body.files);

View File

@@ -3,6 +3,7 @@ const checkBan = require('./checkBan');
const uaParser = require('./uaParser');
const setHeaders = require('./setHeaders');
const loginLimiter = require('./loginLimiter');
const validateModel = require('./validateModel');
const requireJwtAuth = require('./requireJwtAuth');
const uploadLimiters = require('./uploadLimiters');
const registerLimiter = require('./registerLimiter');
@@ -32,6 +33,7 @@ module.exports = {
validateMessageReq,
buildEndpointOption,
validateRegistration,
validateModel,
moderateText,
noIndex,
};

View File

@@ -1,5 +1,6 @@
const axios = require('axios');
const denyRequest = require('./denyRequest');
const { logger } = require('~/config');
async function moderateText(req, res, next) {
if (process.env.OPENAI_MODERATION === 'true') {
@@ -28,7 +29,7 @@ async function moderateText(req, res, next) {
return await denyRequest(req, res, errorMessage);
}
} catch (error) {
console.error('Error in moderateText:', error);
logger.error('Error in moderateText:', error);
const errorMessage = 'error in moderation check';
return await denyRequest(req, res, errorMessage);
}

View File

@@ -1,5 +1,5 @@
const rateLimit = require('express-rate-limit');
const { CacheKeys } = require('librechat-data-provider');
const { ViolationTypes } = require('librechat-data-provider');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
@@ -35,7 +35,7 @@ const createFileUploadHandler = (ip = true) => {
} = getEnvironmentVariables();
return async (req, res) => {
const type = CacheKeys.FILE_UPLOAD_LIMIT;
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
const errorMessage = {
type,
max: ip ? fileUploadIpMax : fileUploadUserMax,

View File

@@ -0,0 +1,47 @@
const { ViolationTypes } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { handleError } = require('~/server/utils');
const { logViolation } = require('~/cache');
/**
* Validates the model of the request.
*
* @async
* @param {Express.Request} req - The Express request object.
* @param {Express.Response} res - The Express response object.
* @param {Function} next - The Express next function.
*/
const validateModel = async (req, res, next) => {
const { model, endpoint } = req.body;
if (!model) {
return handleError(res, { text: 'Model not provided' });
}
const modelsConfig = await getModelsConfig(req);
if (!modelsConfig) {
return handleError(res, { text: 'Models not loaded' });
}
const availableModels = modelsConfig[endpoint];
if (!availableModels) {
return handleError(res, { text: 'Endpoint models not loaded' });
}
let validModel = !!availableModels.find((availableModel) => availableModel === model);
if (validModel) {
return next();
}
const { ILLEGAL_MODEL_REQ_SCORE: score = 5 } = process.env ?? {};
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
const errorMessage = {
type,
};
await logViolation(req, res, type, errorMessage, score);
return handleError(res, { text: 'Illegal model request' });
};
module.exports = validateModel;

View File

@@ -1,9 +1,10 @@
const express = require('express');
const AskController = require('~/server/controllers/AskController');
const { initializeClient } = require('~/server/services/Endpoints/anthropic');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/anthropic');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -12,8 +13,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await AskController(req, res, next, initializeClient);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -5,6 +5,7 @@ const { addTitle } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -13,8 +14,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const { initializeClient } = require('~/server/services/Endpoints/google');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -12,8 +13,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await AskController(req, res, next, initializeClient);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient);
},
);
module.exports = router;

View File

@@ -1,81 +1,88 @@
const express = require('express');
const router = express.Router();
const throttle = require('lodash/throttle');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { validateTools } = require('~/app');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { addTitle } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
createAbortController,
handleAbortError,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
} = require('~/server/middleware');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res) => {
let {
text,
endpointOption,
conversationId,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
logger.debug('[/ask/gptPlugins]', { text, conversationId, ...endpointOption });
let metadata;
let userMessage;
let promptTokens;
let userMessageId;
let responseMessageId;
let lastSavedTimestamp = 0;
let saveDelay = 100;
const sender = getResponseSender({ ...endpointOption, model: endpointOption.modelOptions.model });
const newConvo = !conversationId;
const user = req.user.id;
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res) => {
let {
text,
endpointOption,
conversationId,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
const plugins = [];
logger.debug('[/ask/gptPlugins]', { text, conversationId, ...endpointOption });
const addMetadata = (data) => (metadata = data);
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
userMessageId = data[key].messageId;
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
} else if (!conversationId && key === 'conversationId') {
conversationId = data[key];
let userMessage;
let promptTokens;
let userMessageId;
let responseMessageId;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
});
const newConvo = !conversationId;
const user = req.user.id;
const plugins = [];
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
userMessageId = data[key].messageId;
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
} else if (!conversationId && key === 'conversationId') {
conversationId = data[key];
}
}
}
};
};
let streaming = null;
let timer = null;
const throttledSaveMessage = throttle(saveMessage, 3000, { trailing: false });
let streaming = null;
let timer = null;
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
onProgress: ({ text: partialText }) => {
const currentTimestamp = Date.now();
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
onProgress: ({ text: partialText }) => {
if (timer) {
clearTimeout(timer);
}
if (timer) {
clearTimeout(timer);
}
if (currentTimestamp - lastSavedTimestamp > saveDelay) {
lastSavedTimestamp = currentTimestamp;
saveMessage({
throttledSaveMessage({
messageId: responseMessageId,
sender,
conversationId,
@@ -87,140 +94,131 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
plugins,
user,
});
}
if (saveDelay < 500) {
saveDelay = 500;
}
streaming = new Promise((resolve) => {
timer = setTimeout(() => {
resolve();
}, 250);
});
},
});
streaming = new Promise((resolve) => {
timer = setTimeout(() => {
resolve();
}, 250);
});
},
});
const pluginMap = new Map();
const onAgentAction = async (action, runId) => {
pluginMap.set(runId, action.tool);
sendIntermediateMessage(res, { plugins });
};
const onToolStart = async (tool, input, runId, parentRunId) => {
const pluginName = pluginMap.get(parentRunId);
const latestPlugin = {
runId,
loading: true,
inputs: [input],
latest: pluginName,
outputs: null,
const pluginMap = new Map();
const onAgentAction = async (action, runId) => {
pluginMap.set(runId, action.tool);
sendIntermediateMessage(res, { plugins });
};
if (streaming) {
await streaming;
}
const extraTokens = ':::plugin:::\n';
plugins.push(latestPlugin);
sendIntermediateMessage(res, { plugins }, extraTokens);
};
const onToolStart = async (tool, input, runId, parentRunId) => {
const pluginName = pluginMap.get(parentRunId);
const latestPlugin = {
runId,
loading: true,
inputs: [input],
latest: pluginName,
outputs: null,
};
const onToolEnd = async (output, runId) => {
if (streaming) {
await streaming;
}
if (streaming) {
await streaming;
}
const extraTokens = ':::plugin:::\n';
plugins.push(latestPlugin);
sendIntermediateMessage(res, { plugins }, extraTokens);
};
const pluginIndex = plugins.findIndex((plugin) => plugin.runId === runId);
const onToolEnd = async (output, runId) => {
if (streaming) {
await streaming;
}
if (pluginIndex !== -1) {
plugins[pluginIndex].loading = false;
plugins[pluginIndex].outputs = output;
}
};
const pluginIndex = plugins.findIndex((plugin) => plugin.runId === runId);
const onChainEnd = () => {
saveMessage({ ...userMessage, user });
sendIntermediateMessage(res, { plugins });
};
if (pluginIndex !== -1) {
plugins[pluginIndex].loading = false;
plugins[pluginIndex].outputs = output;
}
};
const getAbortData = () => ({
sender,
conversationId,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugins: plugins.map((p) => ({ ...p, loading: false })),
userMessage,
promptTokens,
});
const { abortController, onStart } = createAbortController(req, res, getAbortData);
const onChainEnd = () => {
saveMessage({ ...userMessage, user });
sendIntermediateMessage(res, { plugins });
};
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
let response = await client.sendMessage(text, {
user,
const getAbortData = () => ({
sender,
conversationId,
parentMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onToolStart,
onToolEnd,
onStart,
addMetadata,
getPartialText,
...endpointOption,
onProgress: progressCallback.call(null, {
res,
text,
parentMessageId: overrideParentMessageId || userMessageId,
plugins,
}),
abortController,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugins: plugins.map((p) => ({ ...p, loading: false })),
userMessage,
promptTokens,
});
const { abortController, onStart } = createAbortController(req, res, getAbortData);
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
if (metadata) {
response = { ...response, ...metadata };
}
let response = await client.sendMessage(text, {
user,
conversationId,
parentMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onToolStart,
onToolEnd,
onStart,
getPartialText,
...endpointOption,
onProgress: progressCallback.call(null, {
res,
text,
parentMessageId: overrideParentMessageId || userMessageId,
plugins,
}),
abortController,
});
logger.debug('[/ask/gptPlugins]', response);
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
await saveMessage({ ...response, user });
logger.debug('[/ask/gptPlugins]', response);
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
requestMessage: userMessage,
responseMessage: response,
});
res.end();
response.plugins = plugins.map((p) => ({ ...p, loading: false }));
await saveMessage({ ...response, user });
if (parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response,
client,
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
requestMessage: userMessage,
responseMessage: response,
});
res.end();
if (parentMessageId === Constants.NO_PARENT && newConvo) {
addTitle(req, {
text,
response,
client,
});
}
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
});
}
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
});
}
});
},
);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const { addTitle, initializeClient } = require('~/server/services/Endpoints/open
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
@@ -13,8 +14,15 @@ const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await AskController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -1,10 +1,10 @@
const { v4 } = require('uuid');
const express = require('express');
const { actionDelimiter } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistant');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { updateAssistant, getAssistant } = require('~/models/Assistant');
const { encryptMetadata } = require('~/server/services/ActionService');
const { logger } = require('~/config');
const router = express.Router();
@@ -17,7 +17,7 @@ const router = express.Router();
*/
router.get('/', async (req, res) => {
try {
res.json(await getActions({ user: req.user.id }));
res.json(await getActions());
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -44,7 +44,10 @@ router.post('/:assistant_id', async (req, res) => {
let metadata = encryptMetadata(_metadata);
const { domain } = metadata;
let { domain } = metadata;
/* Azure doesn't support periods in function names */
domain = domainParser(req, domain, true);
if (!domain) {
return res.status(400).json({ message: 'No domain provided' });
}
@@ -55,9 +58,9 @@ router.post('/:assistant_id', async (req, res) => {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
initialPromises.push(getAssistant({ assistant_id, user: req.user.id }));
initialPromises.push(getAssistant({ assistant_id }));
initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
!!_action_id && initialPromises.push(getActions({ user: req.user.id, action_id }, true));
!!_action_id && initialPromises.push(getActions({ action_id }, true));
/** @type {[AssistantDocument, Assistant, [Action|undefined]]} */
const [assistant_data, assistant, actions_result] = await Promise.all(initialPromises);
@@ -74,14 +77,7 @@ router.post('/:assistant_id', async (req, res) => {
const { actions: _actions = [] } = assistant_data ?? {};
const actions = [];
for (const action of _actions) {
const [action_domain, current_action_id] = action.split(actionDelimiter);
if (action_domain === domain && !_action_id) {
// TODO: dupe check on the frontend
return res.status(400).json({
message: `Action sets cannot have duplicate domains - ${domain} already exists on another action`,
});
}
const [_action_domain, current_action_id] = action.split(actionDelimiter);
if (current_action_id === action_id) {
continue;
}
@@ -115,14 +111,15 @@ router.post('/:assistant_id', async (req, res) => {
const promises = [];
promises.push(
updateAssistant(
{ assistant_id, user: req.user.id },
{ assistant_id },
{
actions,
user: req.user.id,
},
),
);
promises.push(openai.beta.assistants.update(assistant_id, { tools }));
promises.push(updateAction({ action_id, user: req.user.id }, { metadata, assistant_id }));
promises.push(updateAction({ action_id }, { metadata, assistant_id, user: req.user.id }));
/** @type {[AssistantDocument, Assistant, Action]} */
const resolved = await Promise.all(promises);
@@ -147,21 +144,22 @@ router.post('/:assistant_id', async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json
*/
router.delete('/:assistant_id/:action_id', async (req, res) => {
router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
try {
const { assistant_id, action_id } = req.params;
const { assistant_id, action_id, model } = req.params;
req.body.model = model;
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const initialPromises = [];
initialPromises.push(getAssistant({ assistant_id, user: req.user.id }));
initialPromises.push(getAssistant({ assistant_id }));
initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
/** @type {[AssistantDocument, Assistant]} */
const [assistant_data, assistant] = await Promise.all(initialPromises);
const { actions } = assistant_data ?? {};
const { actions = [] } = assistant_data ?? {};
const { tools = [] } = assistant ?? {};
let domain = '';
@@ -173,6 +171,8 @@ router.delete('/:assistant_id/:action_id', async (req, res) => {
return true;
});
domain = domainParser(req, domain, true);
const updatedTools = tools.filter(
(tool) => !(tool.function && tool.function.name.includes(domain)),
);
@@ -180,14 +180,15 @@ router.delete('/:assistant_id/:action_id', async (req, res) => {
const promises = [];
promises.push(
updateAssistant(
{ assistant_id, user: req.user.id },
{ assistant_id },
{
actions: updatedActions,
user: req.user.id,
},
),
);
promises.push(openai.beta.assistants.update(assistant_id, { tools: updatedTools }));
promises.push(deleteAction({ action_id, user: req.user.id }));
promises.push(deleteAction({ action_id }));
await Promise.all(promises);
res.status(200).json({ message: 'Action deleted successfully' });

View File

@@ -1,10 +1,14 @@
const multer = require('multer');
const express = require('express');
const { FileContext, EModelEndpoint } = require('librechat-data-provider');
const { updateAssistant, getAssistants } = require('~/models/Assistant');
const { initializeClient } = require('~/server/services/Endpoints/assistant');
const {
initializeClient,
listAssistantsForAzure,
listAssistants,
} = require('~/server/services/Endpoints/assistants');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { uploadImageBuffer } = require('~/server/services/Files/process');
const { updateAssistant, getAssistants } = require('~/models/Assistant');
const { deleteFileByFilter } = require('~/models/File');
const { logger } = require('~/config');
const actions = require('./actions');
@@ -48,6 +52,10 @@ router.post('/', async (req, res) => {
})
.filter((tool) => tool);
if (openai.locals?.azureOptions) {
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
const assistant = await openai.beta.assistants.create(assistantData);
logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
@@ -101,6 +109,10 @@ router.patch('/:id', async (req, res) => {
})
.filter((tool) => tool);
if (openai.locals?.azureOptions && updateData.model) {
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
res.json(updatedAssistant);
} catch (error) {
@@ -137,19 +149,18 @@ router.delete('/:id', async (req, res) => {
*/
router.get('/', async (req, res) => {
try {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { limit, order, after, before } = req.query;
const response = await openai.beta.assistants.list({
limit,
order,
after,
before,
});
const { limit = 100, order = 'desc', after, before } = req.query;
const query = { limit, order, after, before };
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
/** @type {AssistantListResponse} */
let body = response.body;
let body;
if (azureConfig?.assistants) {
body = await listAssistantsForAzure({ req, res, azureConfig, query });
} else {
({ body } = await listAssistants({ req, res, query }));
}
if (req.app.locals?.[EModelEndpoint.assistants]) {
/** @type {Partial<TAssistantEndpoint>} */
@@ -165,7 +176,7 @@ router.get('/', async (req, res) => {
res.json(body);
} catch (error) {
logger.error('[/assistants] Error listing assistants', error);
res.status(500).json({ error: error.message });
res.status(500).json({ message: 'Error listing assistants' });
}
});
@@ -230,12 +241,13 @@ router.post('/avatar/:assistant_id', upload.single('file'), async (req, res) =>
const promises = [];
promises.push(
updateAssistant(
{ assistant_id, user: req.user.id },
{ assistant_id },
{
avatar: {
filepath: image.filepath,
source: req.app.locals.fileStrategy,
},
user: req.user.id,
},
),
);

View File

@@ -1,6 +1,16 @@
const { v4 } = require('uuid');
const express = require('express');
const { EModelEndpoint, Constants, RunStatus, CacheKeys } = require('librechat-data-provider');
const {
Constants,
RunStatus,
CacheKeys,
FileSources,
ContentTypes,
EModelEndpoint,
ViolationTypes,
ImageVisionTool,
AssistantStreamEvents,
} = require('librechat-data-provider');
const {
initThread,
recordUsage,
@@ -9,18 +19,23 @@ const {
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, isEnabled, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/assistant');
const { createRun, sleep } = require('~/server/services/Runs');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/assistants');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { getTransactions } = require('~/models/Transaction');
const checkBalance = require('~/models/checkBalance');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { logger } = require('~/config');
const router = express.Router();
const {
setHeaders,
handleAbort,
validateModel,
handleAbortError,
// validateEndpoint,
buildEndpointOption,
@@ -28,6 +43,8 @@ const {
router.post('/abort', handleAbort());
const ten_minutes = 1000 * 60 * 10;
/**
* @route POST /
* @desc Chat with an assistant
@@ -36,8 +53,9 @@ router.post('/abort', handleAbort());
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body);
const {
text,
model,
@@ -85,6 +103,16 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
let parentMessageId = _parentId;
/** @type {TMessage[]} */
let previousMessages = [];
/** @type {import('librechat-data-provider').TConversation | null} */
let conversation = null;
/** @type {string[]} */
let file_ids = [];
/** @type {Set<string>} */
let attachedFileIds = new Set();
/** @type {TMessage | null} */
let requestMessage = null;
/** @type {undefined | Promise<ChatCompletion>} */
let visionPromise;
const userMessageId = v4();
const responseMessageId = v4();
@@ -95,15 +123,195 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
const cache = getLogStores(CacheKeys.ABORT_KEYS);
const cacheKey = `${req.user.id}:${conversationId}`;
/** @type {Run | undefined} - The completed run, undefined if incomplete */
let completedRun;
const handleError = async (error) => {
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint: EModelEndpoint.assistants,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug('[/assistants/chat/] Request aborted on close');
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
req.app.locals?.[EModelEndpoint.azureOpenAI].assistants
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(res, messageData, error.message);
} else {
logger.error('[/assistants/chat/]', error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(res, messageData, defaultErrorMessage);
}
await sleep(2000);
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug('[/assistants/chat/] Run already cancelled');
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[/assistants/chat/] Error cancelling run', error);
}
await sleep(2000);
let run;
try {
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[/assistants/chat/] Error fetching or processing run', error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
title: 'New Chat',
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return sendResponse(res, messageData, 'The Assistant run failed');
}
return sendResponse(res, finalEvent);
};
try {
res.on('close', async () => {
if (!completedRun) {
await handleError(new Error('Request closed'));
}
});
if (convoId && !_thread_id) {
completedRun = true;
throw new Error('Missing thread_id for existing conversation');
}
if (!assistant_id) {
completedRun = true;
throw new Error('Missing assistant_id');
}
const checkBalanceBeforeRun = async () => {
if (!isEnabled(process.env.CHECK_BALANCE)) {
return;
}
const transactions =
(await getTransactions({
user: req.user.id,
context: 'message',
conversationId,
})) ?? [];
const totalPreviousTokens = Math.abs(
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
);
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
// 5 is added for labels
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
promptTokens += totalPreviousTokens + promptBuffer;
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance({
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
});
};
/** @type {{ openai: OpenAIClient }} */
const { openai: _openai, client } = await initializeClient({
req,
@@ -114,15 +322,11 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
openai = _openai;
// if (thread_id) {
// previousMessages = await checkMessageGaps({ openai, thread_id, conversationId });
// }
if (previousMessages.length) {
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
}
const userMessage = {
let userMessage = {
role: 'user',
content: text,
metadata: {
@@ -130,75 +334,7 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
},
};
let thread_file_ids = [];
if (convoId) {
const convo = await getConvo(req.user.id, convoId);
if (convo && convo.file_ids) {
thread_file_ids = convo.file_ids;
}
}
const file_ids = files.map(({ file_id }) => file_id);
if (file_ids.length || thread_file_ids.length) {
userMessage.file_ids = file_ids;
openai.attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
}
// TODO: may allow multiple messages to be created beforehand in a future update
const initThreadBody = {
messages: [userMessage],
metadata: {
user: req.user.id,
conversationId,
},
};
const result = await initThread({ openai, body: initThreadBody, thread_id });
thread_id = result.thread_id;
createOnTextProgress({
openai,
conversationId,
userMessageId,
messageId: responseMessageId,
thread_id,
});
const requestMessage = {
user: req.user.id,
text,
messageId: userMessageId,
parentMessageId,
// TODO: make sure client sends correct format for `files`, use zod
files,
file_ids,
conversationId,
isCreatedByUser: true,
assistant_id,
thread_id,
model: assistant_id,
};
previousMessages.push(requestMessage);
await saveUserMessage({ ...requestMessage, model });
const conversation = {
conversationId,
// TODO: title feature
title: 'New Chat',
endpoint: EModelEndpoint.assistants,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
// model,
};
if (file_ids.length) {
conversation.file_ids = file_ids;
}
/** @type {CreateRunBody} */
/** @type {CreateRunBody | undefined} */
const body = {
assistant_id,
model,
@@ -212,51 +348,256 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
body.instructions = instructions;
}
/* NOTE:
* By default, a Run will use the model and tools configuration specified in Assistant object,
* but you can override most of these when creating the Run for added flexibility:
*/
const run = await createRun({
openai,
thread_id,
body,
});
const getRequestFileIds = async () => {
let thread_file_ids = [];
if (convoId) {
const convo = await getConvo(req.user.id, convoId);
if (convo && convo.file_ids) {
thread_file_ids = convo.file_ids;
}
}
run_id = run.id;
await cache.set(cacheKey, `${thread_id}:${run_id}`);
file_ids = files.map(({ file_id }) => file_id);
if (file_ids.length || thread_file_ids.length) {
userMessage.file_ids = file_ids;
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
}
};
sendMessage(res, {
sync: true,
conversationId,
// messages: previousMessages,
requestMessage,
responseMessage: {
user: req.user.id,
messageId: openai.responseMessage.messageId,
parentMessageId: userMessageId,
const addVisionPrompt = async () => {
if (!req.body.endpointOption.attachments) {
return;
}
/** @type {MongoFile[]} */
const attachments = await req.body.endpointOption.attachments;
if (
attachments &&
attachments.every((attachment) => attachment.source === FileSources.openai)
) {
return;
}
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const visionToolIndex = assistant.tools.findIndex(
(tool) => tool?.function && tool?.function?.name === ImageVisionTool.function.name,
);
if (visionToolIndex === -1) {
return;
}
let visionMessage = {
role: 'user',
content: '',
};
const files = await client.addImageURLs(visionMessage, attachments);
if (!visionMessage.image_urls?.length) {
return;
}
const imageCount = visionMessage.image_urls.length;
const plural = imageCount > 1;
visionMessage.content = createVisionPrompt(plural);
visionMessage = formatMessage({ message: visionMessage, endpoint: EModelEndpoint.openAI });
visionPromise = openai.chat.completions.create({
model: 'gpt-4-vision-preview',
messages: [visionMessage],
max_tokens: 4000,
});
const pluralized = plural ? 's' : '';
body.additional_instructions = `${
body.additional_instructions ? `${body.additional_instructions}\n` : ''
}The user has uploaded ${imageCount} image${pluralized}.
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
return files;
};
const initializeThread = async () => {
/** @type {[ undefined | MongoFile[]]}*/
const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
// TODO: may allow multiple messages to be created beforehand in a future update
const initThreadBody = {
messages: [userMessage],
metadata: {
user: req.user.id,
conversationId,
},
};
if (processedFiles) {
for (const file of processedFiles) {
if (file.source !== FileSources.openai) {
attachedFileIds.delete(file.file_id);
const index = file_ids.indexOf(file.file_id);
if (index > -1) {
file_ids.splice(index, 1);
}
}
}
userMessage.file_ids = file_ids;
}
const result = await initThread({ openai, body: initThreadBody, thread_id });
thread_id = result.thread_id;
createOnTextProgress({
openai,
conversationId,
userMessageId,
messageId: responseMessageId,
thread_id,
});
requestMessage = {
user: req.user.id,
text,
messageId: userMessageId,
parentMessageId,
// TODO: make sure client sends correct format for `files`, use zod
files,
file_ids,
conversationId,
isCreatedByUser: true,
assistant_id,
thread_id,
model: assistant_id,
},
};
previousMessages.push(requestMessage);
/* asynchronous */
saveUserMessage({ ...requestMessage, model });
conversation = {
conversationId,
title: 'New Chat',
endpoint: EModelEndpoint.assistants,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
// model,
};
if (file_ids.length) {
conversation.file_ids = file_ids;
}
};
const promises = [initializeThread(), checkBalanceBeforeRun()];
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sync: true,
conversationId,
// messages: previousMessages,
requestMessage,
responseMessage: {
user: req.user.id,
messageId: openai.responseMessage.messageId,
parentMessageId: userMessageId,
conversationId,
assistant_id,
thread_id,
model: assistant_id,
},
});
};
/** @type {RunResponse | typeof StreamRunManager | undefined} */
let response;
const processRun = async (retry = false) => {
if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
openai.attachedFileIds = attachedFileIds;
openai.visionPromise = visionPromise;
if (retry) {
response = await runAssistant({
openai,
thread_id,
run_id,
in_progress: openai.in_progress,
});
return;
}
/* NOTE:
* By default, a Run will use the model and tools configuration specified in Assistant object,
* but you can override most of these when creating the Run for added flexibility:
*/
const run = await createRun({
openai,
thread_id,
body,
});
run_id = run.id;
await cache.set(cacheKey, `${thread_id}:${run_id}`, ten_minutes);
sendInitialResponse();
// todo: retry logic
response = await runAssistant({ openai, thread_id, run_id });
return;
}
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
const handlers = {
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, ten_minutes);
run_id = event.data.id;
sendInitialResponse();
},
};
const streamRunManager = new StreamRunManager({
req,
res,
openai,
handlers,
thread_id,
visionPromise,
attachedFileIds,
responseMessage: openai.responseMessage,
// streamOptions: {
// },
});
await streamRunManager.runAssistant({
thread_id,
body,
});
response = streamRunManager;
};
await processRun();
logger.debug('[/assistants/chat/] response', {
run: response.run,
steps: response.steps,
});
// todo: retry logic
let response = await runAssistant({ openai, thread_id, run_id });
logger.debug('[/assistants/chat/] response', response);
if (response.run.status === RunStatus.CANCELLED) {
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
return res.end();
}
if (response.run.status === RunStatus.IN_PROGRESS) {
response = await runAssistant({
openai,
thread_id,
run_id,
in_progress: openai.in_progress,
});
processRun(true);
}
completedRun = response.run;
/** @type {ResponseMessage} */
const responseMessage = {
...openai.responseMessage,
...(response.responseMessage ?? response.finalMessage),
parentMessageId: userMessageId,
conversationId,
user: req.user.id,
@@ -265,9 +606,6 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
model: assistant_id,
};
// TODO: token count from usage returned in run
// TODO: parse responses, save to db, send to user
sendMessage(res, {
title: 'New Chat',
final: true,
@@ -284,7 +622,7 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {
text,
responseText: openai.responseText,
responseText: response.text,
conversationId,
client,
});
@@ -299,7 +637,7 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
if (!response.run.usage) {
await sleep(3000);
const completedRun = await openai.beta.threads.runs.retrieve(thread_id, run.id);
completedRun = await openai.beta.threads.runs.retrieve(thread_id, response.run.id);
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,
@@ -317,62 +655,7 @@ router.post('/', buildEndpointOption, setHeaders, async (req, res) => {
});
}
} catch (error) {
if (error.message === 'Run cancelled') {
return res.end();
}
logger.error('[/assistants/chat/]', error);
if (!openai || !thread_id || !run_id) {
return res.status(500).json({ error: 'The Assistant run failed to initialize' });
}
try {
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[abortRun] Error cancelling run', error);
}
await sleep(2000);
try {
const run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[/assistants/chat/] Error fetching or processing run', error);
}
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const finalEvent = {
title: 'New Chat',
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
}
res.json(finalEvent);
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return res.status(500).json({ error: 'The Assistant run failed' });
}
await handleError(error);
}
});

View File

@@ -43,6 +43,8 @@ router.get('/', async function (req, res) {
isBirthday() ||
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: req.app.locals.interface,
};
if (typeof process.env.CUSTOM_FOOTER === 'string') {

View File

@@ -1,10 +1,10 @@
const express = require('express');
const { CacheKeys } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistant');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { sleep } = require('~/server/services/Runs/handle');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const router = express.Router();

View File

@@ -4,6 +4,7 @@ const { initializeClient } = require('~/server/services/Endpoints/anthropic');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -12,8 +13,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await EditController(req, res, next, initializeClient);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await EditController(req, res, next, initializeClient);
},
);
module.exports = router;

View File

@@ -5,6 +5,7 @@ const { addTitle } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -13,8 +14,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await EditController(req, res, next, initializeClient, addTitle);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await EditController(req, res, next, initializeClient, addTitle);
},
);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const { initializeClient } = require('~/server/services/Endpoints/google');
const {
setHeaders,
handleAbort,
validateModel,
validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
@@ -12,8 +13,15 @@ const router = express.Router();
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await EditController(req, res, next, initializeClient);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await EditController(req, res, next, initializeClient);
},
);
module.exports = router;

View File

@@ -1,88 +1,94 @@
const express = require('express');
const router = express.Router();
const { validateTools } = require('~/app');
const throttle = require('lodash/throttle');
const { getResponseSender } = require('librechat-data-provider');
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils');
const {
handleAbort,
createAbortController,
handleAbortError,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
} = require('~/server/middleware');
const { sendMessage, createOnProgress, formatSteps, formatAction } = require('~/server/utils');
const { initializeClient } = require('~/server/services/Endpoints/gptPlugins');
const { saveMessage, getConvoTitle, getConvo } = require('~/models');
const { validateTools } = require('~/app');
const { logger } = require('~/config');
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res) => {
let {
text,
generation,
endpointOption,
conversationId,
responseMessageId,
isContinued = false,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res) => {
let {
text,
generation,
endpointOption,
conversationId,
responseMessageId,
isContinued = false,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
logger.debug('[/edit/gptPlugins]', {
text,
generation,
isContinued,
conversationId,
...endpointOption,
});
let metadata;
let userMessage;
let promptTokens;
let lastSavedTimestamp = 0;
let saveDelay = 100;
const sender = getResponseSender({ ...endpointOption, model: endpointOption.modelOptions.model });
const userMessageId = parentMessageId;
const user = req.user.id;
logger.debug('[/edit/gptPlugins]', {
text,
generation,
isContinued,
conversationId,
...endpointOption,
});
const plugin = {
loading: true,
inputs: [],
latest: null,
outputs: null,
};
let userMessage;
let promptTokens;
const sender = getResponseSender({
...endpointOption,
model: endpointOption.modelOptions.model,
});
const userMessageId = parentMessageId;
const user = req.user.id;
const addMetadata = (data) => (metadata = data);
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
const plugin = {
loading: true,
inputs: [],
latest: null,
outputs: null,
};
const getReqData = (data = {}) => {
for (let key in data) {
if (key === 'userMessage') {
userMessage = data[key];
} else if (key === 'responseMessageId') {
responseMessageId = data[key];
} else if (key === 'promptTokens') {
promptTokens = data[key];
}
}
}
};
};
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
generation,
onProgress: ({ text: partialText }) => {
const currentTimestamp = Date.now();
const throttledSaveMessage = throttle(saveMessage, 3000, { trailing: false });
const {
onProgress: progressCallback,
sendIntermediateMessage,
getPartialText,
} = createOnProgress({
generation,
onProgress: ({ text: partialText }) => {
if (plugin.loading === true) {
plugin.loading = false;
}
if (plugin.loading === true) {
plugin.loading = false;
}
if (currentTimestamp - lastSavedTimestamp > saveDelay) {
lastSavedTimestamp = currentTimestamp;
saveMessage({
throttledSaveMessage({
messageId: responseMessageId,
sender,
conversationId,
@@ -94,104 +100,95 @@ router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req,
error: false,
user,
});
}
},
});
if (saveDelay < 500) {
saveDelay = 500;
const onAgentAction = (action, start = false) => {
const formattedAction = formatAction(action);
plugin.inputs.push(formattedAction);
plugin.latest = formattedAction.plugin;
if (!start) {
saveMessage({ ...userMessage, user });
}
},
});
sendIntermediateMessage(res, { plugin });
// logger.debug('PLUGIN ACTION', formattedAction);
};
const onAgentAction = (action, start = false) => {
const formattedAction = formatAction(action);
plugin.inputs.push(formattedAction);
plugin.latest = formattedAction.plugin;
if (!start) {
const onChainEnd = (data) => {
let { intermediateSteps: steps } = data;
plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.';
plugin.loading = false;
saveMessage({ ...userMessage, user });
}
sendIntermediateMessage(res, { plugin });
// logger.debug('PLUGIN ACTION', formattedAction);
};
sendIntermediateMessage(res, { plugin });
// logger.debug('CHAIN END', plugin.outputs);
};
const onChainEnd = (data) => {
let { intermediateSteps: steps } = data;
plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.';
plugin.loading = false;
saveMessage({ ...userMessage, user });
sendIntermediateMessage(res, { plugin });
// logger.debug('CHAIN END', plugin.outputs);
};
const getAbortData = () => ({
sender,
conversationId,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugin: { ...plugin, loading: false },
userMessage,
promptTokens,
});
const { abortController, onStart } = createAbortController(req, res, getAbortData);
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
let response = await client.sendMessage(text, {
user,
generation,
isContinued,
isEdited: true,
conversationId,
parentMessageId,
responseMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onStart,
addMetadata,
...endpointOption,
onProgress: progressCallback.call(null, {
res,
text,
plugin,
parentMessageId: overrideParentMessageId || userMessageId,
}),
abortController,
});
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
if (metadata) {
response = { ...response, ...metadata };
}
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
response.plugin = { ...plugin, loading: false };
await saveMessage({ ...response, user });
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
requestMessage: userMessage,
responseMessage: response,
});
res.end();
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
const getAbortData = () => ({
sender,
conversationId,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId,
text: getPartialText(),
plugin: { ...plugin, loading: false },
userMessage,
promptTokens,
});
}
});
const { abortController, onStart } = createAbortController(req, res, getAbortData);
try {
endpointOption.tools = await validateTools(user, endpointOption.tools);
const { client } = await initializeClient({ req, res, endpointOption });
let response = await client.sendMessage(text, {
user,
generation,
isContinued,
isEdited: true,
conversationId,
parentMessageId,
responseMessageId,
overrideParentMessageId,
getReqData,
onAgentAction,
onChainEnd,
onStart,
...endpointOption,
onProgress: progressCallback.call(null, {
res,
text,
plugin,
parentMessageId: overrideParentMessageId || userMessageId,
}),
abortController,
});
if (overrideParentMessageId) {
response.parentMessageId = overrideParentMessageId;
}
logger.debug('[/edit/gptPlugins] CLIENT RESPONSE', response);
response.plugin = { ...plugin, loading: false };
await saveMessage({ ...response, user });
sendMessage(res, {
title: await getConvoTitle(user, conversationId),
final: true,
conversation: await getConvo(user, conversationId),
requestMessage: userMessage,
responseMessage: response,
});
res.end();
} catch (error) {
const partialText = getPartialText();
handleAbortError(res, req, error, {
partialText,
conversationId,
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
});
}
},
);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const { initializeClient } = require('~/server/services/Endpoints/openAI');
const {
handleAbort,
setHeaders,
validateModel,
validateEndpoint,
buildEndpointOption,
moderateText,
@@ -13,8 +14,15 @@ const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
router.post('/', validateEndpoint, buildEndpointOption, setHeaders, async (req, res, next) => {
await EditController(req, res, next, initializeClient);
});
router.post(
'/',
validateEndpoint,
validateModel,
buildEndpointOption,
setHeaders,
async (req, res, next) => {
await EditController(req, res, next, initializeClient);
},
);
module.exports = router;

View File

@@ -1,12 +1,13 @@
const axios = require('axios');
const fs = require('fs').promises;
const express = require('express');
const { isUUID } = require('librechat-data-provider');
const { isUUID, FileSources } = require('librechat-data-provider');
const {
filterFile,
processFileUpload,
processDeleteRequest,
} = require('~/server/services/Files/process');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
@@ -44,7 +45,7 @@ router.delete('/', async (req, res) => {
return false;
}
if (/^file-/.test(file.file_id)) {
if (/^(file|assistant)-/.test(file.file_id)) {
return true;
}
@@ -65,28 +66,64 @@ router.delete('/', async (req, res) => {
}
});
router.get('/download/:fileId', async (req, res) => {
router.get('/download/:userId/:filepath', async (req, res) => {
try {
const { fileId } = req.params;
const { userId, filepath } = req.params;
const options = {
headers: {
// TODO: Client initialization for OpenAI API Authentication
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
responseType: 'stream',
if (userId !== req.user.id) {
logger.warn(`${errorPrefix} forbidden: ${file_id}`);
return res.status(403).send('Forbidden');
}
const parts = filepath.split('/');
const file_id = parts[2];
const [file] = await getFiles({ file_id });
const errorPrefix = `File download requested by user ${userId}`;
if (!file) {
logger.warn(`${errorPrefix} not found: ${file_id}`);
return res.status(404).send('File not found');
}
if (!file.filepath.includes(userId)) {
logger.warn(`${errorPrefix} forbidden: ${file_id}`);
return res.status(403).send('Forbidden');
}
if (file.source === FileSources.openai && !file.model) {
logger.warn(`${errorPrefix} has no associated model: ${file_id}`);
return res.status(400).send('The model used when creating this file is not available');
}
const { getDownloadStream } = getStrategyFunctions(file.source);
if (!getDownloadStream) {
logger.warn(`${errorPrefix} has no stream method implemented: ${file.source}`);
return res.status(501).send('Not Implemented');
}
const setHeaders = () => {
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-File-Metadata', JSON.stringify(file));
};
const fileResponse = await axios.get(`https://api.openai.com/v1/files/${fileId}`, {
headers: options.headers,
});
const { filename } = fileResponse.data;
const response = await axios.get(`https://api.openai.com/v1/files/${fileId}/content`, options);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
response.data.pipe(res);
/** @type {{ body: import('stream').PassThrough } | undefined} */
let passThrough;
/** @type {ReadableStream | undefined} */
let fileStream;
if (file.source === FileSources.openai) {
req.body = { model: file.model };
const { openai } = await initializeClient({ req, res });
passThrough = await getDownloadStream(file_id, openai);
setHeaders();
passThrough.body.pipe(res);
} else {
fileStream = getDownloadStream(file_id);
setHeaders();
fileStream.pipe(res);
}
} catch (error) {
console.error('Error downloading file:', error);
logger.error('Error downloading file:', error);
res.status(500).send('Error downloading file');
}
});

View File

@@ -15,6 +15,7 @@ const storage = multer.diskStorage({
},
filename: function (req, file, cb) {
req.file_id = crypto.randomUUID();
file.originalname = decodeURIComponent(file.originalname);
cb(null, `${file.originalname}`);
},
});

View File

@@ -1,8 +1,8 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/ModelController');
const { requireJwtAuth } = require('../middleware/');
const { modelController } = require('~/server/controllers/ModelController');
const { requireJwtAuth } = require('~/server/middleware/');
router.get('/', requireJwtAuth, controller);
const router = express.Router();
router.get('/', requireJwtAuth, modelController);
module.exports = router;

View File

@@ -1,18 +1,45 @@
const { AuthTypeEnum } = require('librechat-data-provider');
const { AuthTypeEnum, EModelEndpoint, actionDomainSeparator } = require('librechat-data-provider');
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
const { getActions } = require('~/models/Action');
const { logger } = require('~/config');
/**
* Parses the domain for an action.
*
* Azure OpenAI Assistants API doesn't support periods in function
* names due to `[a-zA-Z0-9_-]*` Regex Validation.
*
* @param {Express.Request} req - Express Request object
* @param {string} domain - The domain for the actoin
* @param {boolean} inverse - If true, replaces periods with `actionDomainSeparator`
* @returns {string} The parsed domain
*/
function domainParser(req, domain, inverse = false) {
if (!domain) {
return;
}
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
return domain;
}
if (inverse) {
return domain.replace(/\./g, actionDomainSeparator);
}
return domain.replace(actionDomainSeparator, '.');
}
/**
* Loads action sets based on the user and assistant ID.
*
* @param {Object} params - The parameters for loading action sets.
* @param {string} params.user - The user identifier.
* @param {string} params.assistant_id - The assistant identifier.
* @param {Object} searchParams - The parameters for loading action sets.
* @param {string} searchParams.user - The user identifier.
* @param {string} searchParams.assistant_id - The assistant identifier.
* @returns {Promise<Action[] | null>} A promise that resolves to an array of actions or `null` if no match.
*/
async function loadActionSets({ user, assistant_id }) {
return await getActions({ user, assistant_id }, true);
async function loadActionSets(searchParams) {
return await getActions(searchParams, true);
}
/**
@@ -40,7 +67,9 @@ function createActionTool({ action, requestBuilder }) {
logger.error(`API call to ${action.metadata.domain} failed`, error);
if (error.response) {
const { status, data } = error.response;
return `API call to ${action.metadata.domain} failed with status ${status}: ${data}`;
return `API call to ${
action.metadata.domain
} failed with status ${status}: ${JSON.stringify(data)}`;
}
return `API call to ${action.metadata.domain} failed.`;
@@ -115,4 +144,5 @@ module.exports = {
createActionTool,
encryptMetadata,
decryptMetadata,
domainParser,
};

View File

@@ -1,8 +1,14 @@
const {
FileSources,
EModelEndpoint,
Constants,
FileSources,
Capabilities,
EModelEndpoint,
defaultSocialLogins,
validateAzureGroups,
mapModelToAzureConfig,
assistantEndpointSchema,
deprecatedAzureVariables,
conflictingAzureVariables,
} = require('librechat-data-provider');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const loadCustomConfig = require('./Config/loadCustomConfig');
@@ -62,31 +68,111 @@ const AppService = async (app) => {
handleRateLimits(config?.rateLimits);
const endpointLocals = {};
if (config?.endpoints?.[EModelEndpoint.assistants]) {
const { disableBuilder, pollIntervalMs, timeoutMs, supportedIds, excludedIds } =
config.endpoints[EModelEndpoint.assistants];
if (supportedIds?.length && excludedIds?.length) {
if (config?.endpoints?.[EModelEndpoint.azureOpenAI]) {
const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI];
const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups);
if (!isValid) {
const errorString = errors.join('\n');
const errorMessage = 'Invalid Azure OpenAI configuration:\n' + errorString;
logger.error(errorMessage);
throw new Error(errorMessage);
}
const assistantModels = [];
const assistantGroups = new Set();
for (const modelName of modelNames) {
mapModelToAzureConfig({ modelName, modelGroupMap, groupMap });
const groupName = modelGroupMap?.[modelName]?.group;
const modelGroup = groupMap?.[groupName];
let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants;
if (supportsAssistants) {
assistantModels.push(modelName);
!assistantGroups.has(groupName) && assistantGroups.add(groupName);
}
}
if (azureConfiguration.assistants && assistantModels.length === 0) {
throw new Error(
'No Azure models are configured to support assistants. Please remove the `assistants` field or configure at least one model to support assistants.',
);
}
endpointLocals[EModelEndpoint.azureOpenAI] = {
modelNames,
modelGroupMap,
groupMap,
assistantModels,
assistantGroups: Array.from(assistantGroups),
...azureConfiguration,
};
deprecatedAzureVariables.forEach(({ key, description }) => {
if (process.env[key]) {
logger.warn(
`The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`,
);
}
});
conflictingAzureVariables.forEach(({ key }) => {
if (process.env[key]) {
logger.warn(
`The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`,
);
}
});
if (azureConfiguration.assistants) {
endpointLocals[EModelEndpoint.assistants] = {
// Note: may need to add retrieval models here in the future
capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter],
};
}
}
if (config?.endpoints?.[EModelEndpoint.assistants]) {
const assistantsConfig = config.endpoints[EModelEndpoint.assistants];
const parsedConfig = assistantEndpointSchema.parse(assistantsConfig);
if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) {
logger.warn(
`Both \`supportedIds\` and \`excludedIds\` are defined for the ${EModelEndpoint.assistants} endpoint; \`excludedIds\` field will be ignored.`,
);
}
const prevConfig = endpointLocals[EModelEndpoint.assistants] ?? {};
/** @type {Partial<TAssistantEndpoint>} */
endpointLocals[EModelEndpoint.assistants] = {
disableBuilder,
pollIntervalMs,
timeoutMs,
supportedIds,
excludedIds,
...prevConfig,
retrievalModels: parsedConfig.retrievalModels,
disableBuilder: parsedConfig.disableBuilder,
pollIntervalMs: parsedConfig.pollIntervalMs,
supportedIds: parsedConfig.supportedIds,
capabilities: parsedConfig.capabilities,
excludedIds: parsedConfig.excludedIds,
timeoutMs: parsedConfig.timeoutMs,
};
}
try {
const response = await fetch(`${process.env.RAG_API_URL}/health`);
if (response?.ok && response?.status === 200) {
logger.info(`RAG API is running and reachable at ${process.env.RAG_API_URL}.`);
}
} catch (error) {
logger.warn(
`RAG API is either not running or not reachable at ${process.env.RAG_API_URL}, you may experience errors with file uploads.`,
);
}
app.locals = {
socialLogins,
availableTools,
fileStrategy,
fileConfig: config?.fileConfig,
interface: config?.interface,
paths,
...endpointLocals,
};

View File

@@ -1,4 +1,11 @@
const { FileSources, defaultSocialLogins } = require('librechat-data-provider');
const {
FileSources,
EModelEndpoint,
defaultSocialLogins,
validateAzureGroups,
deprecatedAzureVariables,
conflictingAzureVariables,
} = require('librechat-data-provider');
const AppService = require('./AppService');
@@ -32,6 +39,43 @@ jest.mock('./ToolService', () => ({
}),
}));
const azureGroups = [
{
group: 'librechat-westus',
apiKey: '${WESTUS_API_KEY}',
instanceName: 'librechat-westus',
version: '2023-12-01-preview',
models: {
'gpt-4-vision-preview': {
deploymentName: 'gpt-4-vision-preview',
version: '2024-02-15-preview',
},
'gpt-3.5-turbo': {
deploymentName: 'gpt-35-turbo',
},
'gpt-3.5-turbo-1106': {
deploymentName: 'gpt-35-turbo-1106',
},
'gpt-4': {
deploymentName: 'gpt-4',
},
'gpt-4-1106-preview': {
deploymentName: 'gpt-4-1106-preview',
},
},
},
{
group: 'librechat-eastus',
apiKey: '${EASTUS_API_KEY}',
instanceName: 'librechat-eastus',
deploymentName: 'gpt-4-turbo',
version: '2024-02-15-preview',
models: {
'gpt-4-turbo': true,
},
},
];
describe('AppService', () => {
let app;
@@ -122,11 +166,11 @@ describe('AppService', () => {
});
});
it('should correctly configure endpoints based on custom config', async () => {
it('should correctly configure Assistants endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
assistants: {
[EModelEndpoint.assistants]: {
disableBuilder: true,
pollIntervalMs: 5000,
timeoutMs: 30000,
@@ -138,8 +182,8 @@ describe('AppService', () => {
await AppService(app);
expect(app.locals).toHaveProperty('assistants');
expect(app.locals.assistants).toEqual(
expect(app.locals).toHaveProperty(EModelEndpoint.assistants);
expect(app.locals[EModelEndpoint.assistants]).toEqual(
expect.objectContaining({
disableBuilder: true,
pollIntervalMs: 5000,
@@ -149,6 +193,34 @@ describe('AppService', () => {
);
});
it('should correctly configure Azure OpenAI endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
[EModelEndpoint.azureOpenAI]: {
groups: azureGroups,
},
},
}),
);
process.env.WESTUS_API_KEY = 'westus-key';
process.env.EASTUS_API_KEY = 'eastus-key';
await AppService(app);
expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI);
const azureConfig = app.locals[EModelEndpoint.azureOpenAI];
expect(azureConfig).toHaveProperty('modelNames');
expect(azureConfig).toHaveProperty('modelGroupMap');
expect(azureConfig).toHaveProperty('groupMap');
const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups);
expect(azureConfig.modelNames).toEqual(modelNames);
expect(azureConfig.modelGroupMap).toEqual(modelGroupMap);
expect(azureConfig.groupMap).toEqual(groupMap);
});
it('should not modify FILE_UPLOAD environment variables without rate limits', async () => {
// Setup initial environment variables
process.env.FILE_UPLOAD_IP_MAX = '10';
@@ -213,7 +285,7 @@ describe('AppService', () => {
});
});
describe('AppService updating app.locals', () => {
describe('AppService updating app.locals and issuing warnings', () => {
let app;
let initialEnv;
@@ -309,4 +381,56 @@ describe('AppService updating app.locals', () => {
expect.stringContaining('Both `supportedIds` and `excludedIds` are defined'),
);
});
it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
[EModelEndpoint.azureOpenAI]: {
groups: azureGroups,
},
},
}),
);
deprecatedAzureVariables.forEach((varInfo) => {
process.env[varInfo.key] = 'test';
});
const app = { locals: {} };
await require('./AppService')(app);
const { logger } = require('~/config');
deprecatedAzureVariables.forEach(({ key, description }) => {
expect(logger.warn).toHaveBeenCalledWith(
`The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`,
);
});
});
it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
[EModelEndpoint.azureOpenAI]: {
groups: azureGroups,
},
},
}),
);
conflictingAzureVariables.forEach((varInfo) => {
process.env[varInfo.key] = 'test';
});
const app = { locals: {} };
await require('./AppService')(app);
const { logger } = require('~/config');
conflictingAzureVariables.forEach(({ key }) => {
expect(logger.warn).toHaveBeenCalledWith(
`The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`,
);
});
});
});

View File

@@ -1,21 +1,19 @@
const path = require('path');
const { klona } = require('klona');
const {
StepTypes,
RunStatus,
StepStatus,
FilePurpose,
ContentTypes,
ToolCallTypes,
imageExtRegex,
imageGenTools,
EModelEndpoint,
defaultOrderQuery,
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { RunManager, waitForRun, sleep } = require('~/server/services/Runs');
const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage } = require('~/server/utils');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { RunManager, waitForRun } = require('~/server/services/Runs');
const { processMessages } = require('~/server/services/Threads');
const { TextStream } = require('~/app/clients');
const { logger } = require('~/config');
@@ -230,17 +228,13 @@ function createInProgressHandler(openai, thread_id, messages) {
const { file_id } = output.image;
const file = await retrieveAndProcessFile({
openai,
client: openai,
file_id,
basename: `${file_id}.png`,
});
// toolCall.asset_pointer = file.filepath;
const prelimImage = {
file_id,
filename: path.basename(file.filepath),
filepath: file.filepath,
height: file.height,
width: file.width,
};
const prelimImage = file;
// check if every key has a value before adding to content
const prelimImageKeys = Object.keys(prelimImage);
const validImageFile = prelimImageKeys.every((key) => prelimImage[key]);
@@ -286,6 +280,9 @@ function createInProgressHandler(openai, thread_id, messages) {
openai.seenCompletedMessages.add(message_id);
const message = await openai.beta.threads.messages.retrieve(thread_id, message_id);
if (!message?.content?.length) {
return;
}
messages.push(message);
let messageIndex = openai.mappedOrder.get(step.id);
@@ -296,7 +293,7 @@ function createInProgressHandler(openai, thread_id, messages) {
openai.index++;
}
const result = await processMessages(openai, [message]);
const result = await processMessages({ openai, client: openai, messages: [message] });
openai.addContentData({
[ContentTypes.TEXT]: { value: result.text },
type: ContentTypes.TEXT,
@@ -315,8 +312,8 @@ function createInProgressHandler(openai, thread_id, messages) {
res: openai.res,
index: messageIndex,
messageId: openai.responseMessage.messageId,
conversationId: openai.responseMessage.conversationId,
type: ContentTypes.TEXT,
stream: true,
thread_id,
});
@@ -413,7 +410,13 @@ async function runAssistant({
// const { messages: sortedMessages, text } = await processMessages(openai, messages);
// return { run, steps, messages: sortedMessages, text };
const sortedMessages = messages.sort((a, b) => a.created_at - b.created_at);
return { run, steps, messages: sortedMessages };
return {
run,
steps,
messages: sortedMessages,
finalMessage: openai.responseMessage,
text: openai.responseText,
};
}
const { submit_tool_outputs } = run.required_action;
@@ -444,98 +447,8 @@ async function runAssistant({
});
}
/**
* Sorts, processes, and flattens messages to a single string.
*
* @param {OpenAIClient} openai - The OpenAI client instance.
* @param {ThreadMessage[]} messages - An array of messages.
* @returns {Promise<{messages: ThreadMessage[], text: string}>} The sorted messages and the flattened text.
*/
async function processMessages(openai, messages = []) {
const sorted = messages.sort((a, b) => a.created_at - b.created_at);
let text = '';
for (const message of sorted) {
message.files = [];
for (const content of message.content) {
const processImageFile =
content.type === 'image_file' && !openai.processedFileIds.has(content.image_file?.file_id);
if (processImageFile) {
const { file_id } = content.image_file;
const file = await retrieveAndProcessFile({ openai, file_id, basename: `${file_id}.png` });
openai.processedFileIds.add(file_id);
message.files.push(file);
continue;
}
text += (content.text?.value ?? '') + ' ';
logger.debug('[processMessages] Processing message:', { value: text });
// Process annotations if they exist
if (!content.text?.annotations?.length) {
continue;
}
logger.debug('[processMessages] Processing annotations:', content.text.annotations);
for (const annotation of content.text.annotations) {
logger.debug('Current annotation:', annotation);
let file;
const processFilePath =
annotation.file_path && !openai.processedFileIds.has(annotation.file_path?.file_id);
if (processFilePath) {
const basename = imageExtRegex.test(annotation.text)
? path.basename(annotation.text)
: null;
file = await retrieveAndProcessFile({
openai,
file_id: annotation.file_path.file_id,
basename,
});
openai.processedFileIds.add(annotation.file_path.file_id);
}
const processFileCitation =
annotation.file_citation &&
!openai.processedFileIds.has(annotation.file_citation?.file_id);
if (processFileCitation) {
file = await retrieveAndProcessFile({
openai,
file_id: annotation.file_citation.file_id,
unknownType: true,
});
openai.processedFileIds.add(annotation.file_citation.file_id);
}
if (!file && (annotation.file_path || annotation.file_citation)) {
const { file_id } = annotation.file_citation || annotation.file_path || {};
file = await retrieveAndProcessFile({ openai, file_id, unknownType: true });
openai.processedFileIds.add(file_id);
}
if (!file) {
continue;
}
if (file.purpose && file.purpose === FilePurpose.Assistants) {
text = text.replace(annotation.text, file.filename);
} else if (file.filepath) {
text = text.replace(annotation.text, file.filepath);
}
message.files.push(file);
}
}
}
return { messages: sorted, text };
}
module.exports = {
getResponse,
runAssistant,
processMessages,
createOnTextProgress,
};

View File

@@ -1,6 +1,7 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { registerSchema, errorsToString } = require('~/strategies/validators');
const { errorsToString } = require('librechat-data-provider');
const { registerSchema } = require('~/strategies/validators');
const getCustomConfig = require('~/server/services/Config/getCustomConfig');
const Token = require('~/models/schema/tokenSchema');
const { sendEmail } = require('~/server/utils');
@@ -171,8 +172,10 @@ const requestPasswordReset = async (email) => {
user.email,
'Password Reset Request',
{
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
link: link,
year: new Date().getFullYear(),
},
'requestPasswordReset.handlebars',
);
@@ -213,7 +216,9 @@ const resetPassword = async (userId, token, password) => {
user.email,
'Password Reset Successfully',
{
appName: process.env.APP_TITLE || 'LibreChat',
name: user.name,
year: new Date().getFullYear(),
},
'passwordReset.handlebars',
);

View File

@@ -1,4 +1,5 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided, generateConfig } = require('~/server/utils');
const {
OPENAI_API_KEY: openAIApiKey,
@@ -9,17 +10,16 @@ const {
BINGAI_TOKEN: bingToken,
PLUGINS_USE_AZURE,
GOOGLE_KEY: googleKey,
OPENAI_REVERSE_PROXY,
AZURE_OPENAI_BASEURL,
ASSISTANTS_BASE_URL,
} = process.env ?? {};
const useAzurePlugins = !!PLUGINS_USE_AZURE;
const userProvidedOpenAI = useAzurePlugins
? azureOpenAIApiKey === 'user_provided'
: openAIApiKey === 'user_provided';
function isUserProvided(key) {
return key ? { userProvide: key === 'user_provided' } : false;
}
? isUserProvided(azureOpenAIApiKey)
: isUserProvided(openAIApiKey);
module.exports = {
config: {
@@ -28,11 +28,11 @@ module.exports = {
useAzurePlugins,
userProvidedOpenAI,
googleKey,
[EModelEndpoint.openAI]: isUserProvided(openAIApiKey),
[EModelEndpoint.assistants]: isUserProvided(assistantsApiKey),
[EModelEndpoint.azureOpenAI]: isUserProvided(azureOpenAIApiKey),
[EModelEndpoint.chatGPTBrowser]: isUserProvided(chatGPTToken),
[EModelEndpoint.anthropic]: isUserProvided(anthropicApiKey),
[EModelEndpoint.bingAI]: isUserProvided(bingToken),
[EModelEndpoint.openAI]: generateConfig(openAIApiKey, OPENAI_REVERSE_PROXY),
[EModelEndpoint.assistants]: generateConfig(assistantsApiKey, ASSISTANTS_BASE_URL, true),
[EModelEndpoint.azureOpenAI]: generateConfig(azureOpenAIApiKey, AZURE_OPENAI_BASEURL),
[EModelEndpoint.chatGPTBrowser]: generateConfig(chatGPTToken),
[EModelEndpoint.anthropic]: generateConfig(anthropicApiKey),
[EModelEndpoint.bingAI]: generateConfig(bingToken),
},
};

View File

@@ -1,12 +1,16 @@
const { availableTools } = require('~/app/clients/tools');
const { EModelEndpoint } = require('librechat-data-provider');
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } =
require('./EndpointService').config;
const { availableTools } = require('~/app/clients/tools');
const { isUserProvided } = require('~/server/utils');
const { config } = require('./EndpointService');
const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, googleKey } = config;
/**
* Load async endpoints and return a configuration object
* @param {Express.Request} req - The request object
*/
async function loadAsyncEndpoints() {
async function loadAsyncEndpoints(req) {
let i = 0;
let serviceKey, googleUserProvides;
try {
@@ -17,7 +21,7 @@ async function loadAsyncEndpoints() {
}
}
if (googleKey === 'user_provided') {
if (isUserProvided(googleKey)) {
googleUserProvides = true;
if (i <= 1) {
i++;
@@ -35,13 +39,18 @@ async function loadAsyncEndpoints() {
const google = serviceKey || googleKey ? { userProvide: googleUserProvides } : false;
const useAzure = req.app.locals[EModelEndpoint.azureOpenAI]?.plugins;
const gptPlugins =
openAIApiKey || azureOpenAIApiKey
useAzure || openAIApiKey || azureOpenAIApiKey
? {
plugins,
availableAgents: ['classic', 'functions'],
userProvide: userProvidedOpenAI,
azure: useAzurePlugins,
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
config[EModelEndpoint.azureOpenAI]?.userProvideURL,
azure: useAzurePlugins || useAzure,
}
: false;

View File

@@ -1,11 +1,13 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided, extractEnvVariable } = require('~/server/utils');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { isUserProvided } = require('~/server/utils');
const getCustomConfig = require('./getCustomConfig');
/**
* Load config endpoints from the cached configuration object
* @function loadConfigEndpoints */
async function loadConfigEndpoints() {
* @param {Express.Request} req - The request object
* @returns {Promise<TEndpointsConfig>} A promise that resolves to an object containing the endpoints configuration
*/
async function loadConfigEndpoints(req) {
const customConfig = await getCustomConfig();
if (!customConfig) {
@@ -42,6 +44,20 @@ async function loadConfigEndpoints() {
}
}
if (req.app.locals[EModelEndpoint.azureOpenAI]) {
/** @type {Omit<TConfig, 'order'>} */
endpointsConfig[EModelEndpoint.azureOpenAI] = {
userProvide: false,
};
}
if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
/** @type {Omit<TConfig, 'order'>} */
endpointsConfig[EModelEndpoint.assistants] = {
userProvide: false,
};
}
return endpointsConfig;
}

View File

@@ -1,6 +1,6 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided, extractEnvVariable } = require('~/server/utils');
const { EModelEndpoint, extractEnvVariable } = require('librechat-data-provider');
const { fetchModels } = require('~/server/services/ModelService');
const { isUserProvided } = require('~/server/utils');
const getCustomConfig = require('./getCustomConfig');
/**
@@ -17,6 +17,21 @@ async function loadConfigModels(req) {
const { endpoints = {} } = customConfig ?? {};
const modelsConfig = {};
const azureEndpoint = endpoints[EModelEndpoint.azureOpenAI];
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
const { modelNames } = azureConfig ?? {};
if (modelNames && azureEndpoint) {
modelsConfig[EModelEndpoint.azureOpenAI] = modelNames;
}
if (modelNames && azureEndpoint && azureEndpoint.plugins) {
modelsConfig[EModelEndpoint.gptPlugins] = modelNames;
}
if (azureEndpoint?.assistants && azureConfig.assistantModels) {
modelsConfig[EModelEndpoint.assistants] = azureConfig.assistantModels;
}
if (!Array.isArray(endpoints[EModelEndpoint.custom])) {
return modelsConfig;
@@ -31,21 +46,34 @@ async function loadConfigModels(req) {
(endpoint.models.fetch || endpoint.models.default),
);
const fetchPromisesMap = {}; // Map for promises keyed by baseURL
const baseUrlToNameMap = {}; // Map to associate baseURLs with names
/**
* @type {Record<string, string[]>}
* Map for promises keyed by unique combination of baseURL and apiKey */
const fetchPromisesMap = {};
/**
* @type {Record<string, string[]>}
* Map to associate unique keys with endpoint names; note: one key may can correspond to multiple endpoints */
const uniqueKeyToEndpointsMap = {};
/**
* @type {Record<string, Partial<TEndpoint>>}
* Map to associate endpoint names to their configurations */
const endpointsMap = {};
for (let i = 0; i < customEndpoints.length; i++) {
const endpoint = customEndpoints[i];
const { models, name, baseURL, apiKey } = endpoint;
endpointsMap[name] = endpoint;
const API_KEY = extractEnvVariable(apiKey);
const BASE_URL = extractEnvVariable(baseURL);
const uniqueKey = `${BASE_URL}__${API_KEY}`;
modelsConfig[name] = [];
if (models.fetch && !isUserProvided(API_KEY) && !isUserProvided(BASE_URL)) {
fetchPromisesMap[BASE_URL] =
fetchPromisesMap[BASE_URL] ||
fetchPromisesMap[uniqueKey] =
fetchPromisesMap[uniqueKey] ||
fetchModels({
user: req.user.id,
baseURL: BASE_URL,
@@ -53,8 +81,8 @@ async function loadConfigModels(req) {
name,
userIdQuery: models.userIdQuery,
});
baseUrlToNameMap[BASE_URL] = baseUrlToNameMap[BASE_URL] || [];
baseUrlToNameMap[BASE_URL].push(name);
uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || [];
uniqueKeyToEndpointsMap[uniqueKey].push(name);
continue;
}
@@ -64,15 +92,16 @@ async function loadConfigModels(req) {
}
const fetchedData = await Promise.all(Object.values(fetchPromisesMap));
const baseUrls = Object.keys(fetchPromisesMap);
const uniqueKeys = Object.keys(fetchPromisesMap);
for (let i = 0; i < fetchedData.length; i++) {
const currentBaseUrl = baseUrls[i];
const currentKey = uniqueKeys[i];
const modelData = fetchedData[i];
const associatedNames = baseUrlToNameMap[currentBaseUrl];
const associatedNames = uniqueKeyToEndpointsMap[currentKey];
for (const name of associatedNames) {
modelsConfig[name] = modelData;
const endpoint = endpointsMap[name];
modelsConfig[name] = !modelData?.length ? endpoint.models.default ?? [] : modelData;
}
}

View File

@@ -0,0 +1,329 @@
const { fetchModels } = require('~/server/services/ModelService');
const loadConfigModels = require('./loadConfigModels');
const getCustomConfig = require('./getCustomConfig');
jest.mock('~/server/services/ModelService');
jest.mock('./getCustomConfig');
const exampleConfig = {
endpoints: {
custom: [
{
name: 'Mistral',
apiKey: '${MY_PRECIOUS_MISTRAL_KEY}',
baseURL: 'https://api.mistral.ai/v1',
models: {
default: ['mistral-tiny', 'mistral-small', 'mistral-medium', 'mistral-large-latest'],
fetch: true,
},
dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'],
},
{
name: 'OpenRouter',
apiKey: '${MY_OPENROUTER_API_KEY}',
baseURL: 'https://openrouter.ai/api/v1',
models: {
default: ['gpt-3.5-turbo'],
fetch: true,
},
dropParams: ['stop'],
},
{
name: 'groq',
apiKey: 'user_provided',
baseURL: 'https://api.groq.com/openai/v1/',
models: {
default: ['llama2-70b-4096', 'mixtral-8x7b-32768'],
fetch: false,
},
},
{
name: 'Ollama',
apiKey: 'user_provided',
baseURL: 'http://localhost:11434/v1/',
models: {
default: ['mistral', 'llama2:13b'],
fetch: false,
},
},
],
},
};
describe('loadConfigModels', () => {
const mockRequest = { app: { locals: {} }, user: { id: 'testUserId' } };
const originalEnv = process.env;
beforeEach(() => {
jest.resetAllMocks();
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should return an empty object if customConfig is null', async () => {
getCustomConfig.mockResolvedValue(null);
const result = await loadConfigModels(mockRequest);
expect(result).toEqual({});
});
it('handles azure models and endpoint correctly', async () => {
mockRequest.app.locals.azureOpenAI = { modelNames: ['model1', 'model2'] };
getCustomConfig.mockResolvedValue({
endpoints: {
azureOpenAI: {
models: ['model1', 'model2'],
},
},
});
const result = await loadConfigModels(mockRequest);
expect(result.azureOpenAI).toEqual(['model1', 'model2']);
});
it('fetches custom models based on the unique key', async () => {
process.env.BASE_URL = 'http://example.com';
process.env.API_KEY = 'some-api-key';
const customEndpoints = {
custom: [
{
baseURL: '${BASE_URL}',
apiKey: '${API_KEY}',
name: 'CustomModel',
models: { fetch: true },
},
],
};
getCustomConfig.mockResolvedValue({ endpoints: customEndpoints });
fetchModels.mockResolvedValue(['customModel1', 'customModel2']);
const result = await loadConfigModels(mockRequest);
expect(fetchModels).toHaveBeenCalled();
expect(result.CustomModel).toEqual(['customModel1', 'customModel2']);
});
it('correctly associates models to names using unique keys', async () => {
getCustomConfig.mockResolvedValue({
endpoints: {
custom: [
{
baseURL: 'http://example.com',
apiKey: 'API_KEY1',
name: 'Model1',
models: { fetch: true },
},
{
baseURL: 'http://example.com',
apiKey: 'API_KEY2',
name: 'Model2',
models: { fetch: true },
},
],
},
});
fetchModels.mockImplementation(({ apiKey }) =>
Promise.resolve(apiKey === 'API_KEY1' ? ['model1Data'] : ['model2Data']),
);
const result = await loadConfigModels(mockRequest);
expect(result.Model1).toEqual(['model1Data']);
expect(result.Model2).toEqual(['model2Data']);
});
it('correctly handles multiple endpoints with the same baseURL but different apiKeys', async () => {
// Mock the custom configuration to simulate the user's scenario
getCustomConfig.mockResolvedValue({
endpoints: {
custom: [
{
name: 'LiteLLM',
apiKey: '${LITELLM_ALL_MODELS}',
baseURL: '${LITELLM_HOST}',
models: { fetch: true },
},
{
name: 'OpenAI',
apiKey: '${LITELLM_OPENAI_MODELS}',
baseURL: '${LITELLM_SECOND_HOST}',
models: { fetch: true },
},
{
name: 'Google',
apiKey: '${LITELLM_GOOGLE_MODELS}',
baseURL: '${LITELLM_SECOND_HOST}',
models: { fetch: true },
},
],
},
});
// Mock `fetchModels` to return different models based on the apiKey
fetchModels.mockImplementation(({ apiKey }) => {
switch (apiKey) {
case '${LITELLM_ALL_MODELS}':
return Promise.resolve(['AllModel1', 'AllModel2']);
case '${LITELLM_OPENAI_MODELS}':
return Promise.resolve(['OpenAIModel']);
case '${LITELLM_GOOGLE_MODELS}':
return Promise.resolve(['GoogleModel']);
default:
return Promise.resolve([]);
}
});
const result = await loadConfigModels(mockRequest);
// Assert that the models are correctly fetched and mapped based on unique keys
expect(result.LiteLLM).toEqual(['AllModel1', 'AllModel2']);
expect(result.OpenAI).toEqual(['OpenAIModel']);
expect(result.Google).toEqual(['GoogleModel']);
// Ensure that fetchModels was called with correct parameters
expect(fetchModels).toHaveBeenCalledTimes(3);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: '${LITELLM_ALL_MODELS}' }),
);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: '${LITELLM_OPENAI_MODELS}' }),
);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: '${LITELLM_GOOGLE_MODELS}' }),
);
});
it('loads models based on custom endpoint configuration respecting fetch rules', async () => {
process.env.MY_PRECIOUS_MISTRAL_KEY = 'actual_mistral_api_key';
process.env.MY_OPENROUTER_API_KEY = 'actual_openrouter_api_key';
// Setup custom configuration with specific API keys for Mistral and OpenRouter
// and "user_provided" for groq and Ollama, indicating no fetch for the latter two
getCustomConfig.mockResolvedValue(exampleConfig);
// Assuming fetchModels would be called only for Mistral and OpenRouter
fetchModels.mockImplementation(({ name }) => {
switch (name) {
case 'Mistral':
return Promise.resolve([
'mistral-tiny',
'mistral-small',
'mistral-medium',
'mistral-large-latest',
]);
case 'OpenRouter':
return Promise.resolve(['gpt-3.5-turbo']);
default:
return Promise.resolve([]);
}
});
const result = await loadConfigModels(mockRequest);
// Since fetch is true and apiKey is not "user_provided", fetching occurs for Mistral and OpenRouter
expect(result.Mistral).toEqual([
'mistral-tiny',
'mistral-small',
'mistral-medium',
'mistral-large-latest',
]);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Mistral',
apiKey: process.env.MY_PRECIOUS_MISTRAL_KEY,
}),
);
expect(result.OpenRouter).toEqual(['gpt-3.5-turbo']);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({
name: 'OpenRouter',
apiKey: process.env.MY_OPENROUTER_API_KEY,
}),
);
// For groq and Ollama, since the apiKey is "user_provided", models should not be fetched
// Depending on your implementation's behavior regarding "default" models without fetching,
// you may need to adjust the following assertions:
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default);
expect(result.Ollama).toBe(exampleConfig.endpoints.custom[3].models.default);
// Verifying fetchModels was not called for groq and Ollama
expect(fetchModels).not.toHaveBeenCalledWith(
expect.objectContaining({
name: 'groq',
}),
);
expect(fetchModels).not.toHaveBeenCalledWith(
expect.objectContaining({
name: 'Ollama',
}),
);
});
it('falls back to default models if fetching returns an empty array', async () => {
getCustomConfig.mockResolvedValue({
endpoints: {
custom: [
{
name: 'EndpointWithSameFetchKey',
apiKey: 'API_KEY',
baseURL: 'http://example.com',
models: {
fetch: true,
default: ['defaultModel1'],
},
},
{
name: 'EmptyFetchModel',
apiKey: 'API_KEY',
baseURL: 'http://example.com',
models: {
fetch: true,
default: ['defaultModel1', 'defaultModel2'],
},
},
],
},
});
fetchModels.mockResolvedValue([]);
const result = await loadConfigModels(mockRequest);
expect(fetchModels).toHaveBeenCalledTimes(1);
expect(result.EmptyFetchModel).toEqual(['defaultModel1', 'defaultModel2']);
});
it('falls back to default models if fetching returns a falsy value', async () => {
getCustomConfig.mockResolvedValue({
endpoints: {
custom: [
{
name: 'FalsyFetchModel',
apiKey: 'API_KEY',
baseURL: 'http://example.com',
models: {
fetch: true,
default: ['defaultModel1', 'defaultModel2'],
},
},
],
},
});
fetchModels.mockResolvedValue(false);
const result = await loadConfigModels(mockRequest);
expect(fetchModels).toHaveBeenCalledWith(
expect.objectContaining({
name: 'FalsyFetchModel',
apiKey: 'API_KEY',
}),
);
expect(result.FalsyFetchModel).toEqual(['defaultModel1', 'defaultModel2']);
});
});

View File

@@ -1,11 +1,13 @@
const path = require('path');
const { CacheKeys, configSchema } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const axios = require('axios');
const yaml = require('js-yaml');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const configPath = path.resolve(projectRoot, 'librechat.yaml');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
let i = 0;
@@ -16,19 +18,46 @@ let i = 0;
* @returns {Promise<TCustomConfig | null>} A promise that resolves to null or the custom config object.
* */
async function loadCustomConfig() {
const customConfig = loadYaml(configPath);
if (!customConfig) {
i === 0 &&
logger.info(
'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n',
);
i === 0 && i++;
return null;
// Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
let customConfig;
if (/^https?:\/\//.test(configPath)) {
try {
const response = await axios.get(configPath);
customConfig = response.data;
} catch (error) {
i === 0 && logger.error(`Failed to fetch the remote config file from ${configPath}`, error);
i === 0 && i++;
return null;
}
} else {
customConfig = loadYaml(configPath);
if (!customConfig) {
i === 0 &&
logger.info(
'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n',
);
i === 0 && i++;
return null;
}
}
if (typeof customConfig === 'string') {
try {
customConfig = yaml.load(customConfig);
} catch (parseError) {
i === 0 && logger.info(`Failed to parse the YAML config from ${configPath}`, parseError);
i === 0 && i++;
return null;
}
}
const result = configSchema.strict().safeParse(customConfig);
if (!result.success) {
logger.error(`Invalid custom config file at ${configPath}`, result.error);
i === 0 && logger.error(`Invalid custom config file at ${configPath}`, result.error);
i === 0 && i++;
return null;
} else {
logger.info('Custom config file loaded:');
@@ -41,8 +70,6 @@ async function loadCustomConfig() {
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
}
// TODO: handle remote config
return customConfig;
}

View File

@@ -0,0 +1,153 @@
jest.mock('axios');
jest.mock('~/cache/getLogStores');
jest.mock('~/utils/loadYaml');
const axios = require('axios');
const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
describe('loadCustomConfig', () => {
const mockSet = jest.fn();
const mockCache = { set: mockSet };
beforeEach(() => {
jest.resetAllMocks();
delete process.env.CONFIG_PATH;
getLogStores.mockReturnValue(mockCache);
});
it('should return null and log error if remote config fetch fails', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockRejectedValue(new Error('Network error'));
const result = await loadCustomConfig();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it('should return null for an invalid local config file', async () => {
process.env.CONFIG_PATH = 'localConfig.yaml';
loadYaml.mockReturnValueOnce(null);
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should parse, validate, and cache a valid local configuration', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null and log if config schema validation fails', async () => {
const invalidConfig = { invalidField: true };
process.env.CONFIG_PATH = 'invalidConfig.yaml';
loadYaml.mockReturnValueOnce(invalidConfig);
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should handle and return null on YAML parse error for a string response from remote', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockResolvedValue({ data: 'invalidYAMLContent' });
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should return the custom config object for a valid remote config file', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockResolvedValue({ data: mockConfig });
const result = await loadCustomConfig();
expect(result).toEqual(mockConfig);
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
});
it('should return null if the remote config file is not found', async () => {
process.env.CONFIG_PATH = 'http://example.com/config.yaml';
axios.get.mockRejectedValue({ response: { status: 404 } });
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should return null if the local config file is not found', async () => {
process.env.CONFIG_PATH = 'nonExistentConfig.yaml';
loadYaml.mockReturnValueOnce(null);
const result = await loadCustomConfig();
expect(result).toBeNull();
});
it('should not cache the config if cache is set to false', async () => {
const mockConfig = {
version: '1.0',
cache: false,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
expect(mockSet).not.toHaveBeenCalled();
});
it('should log the loaded custom config', async () => {
const mockConfig = {
version: '1.0',
cache: true,
endpoints: {
custom: [
{
name: 'mistral',
apiKey: 'user_provided',
baseURL: 'https://api.mistral.ai/v1',
},
],
},
};
process.env.CONFIG_PATH = 'validConfig.yaml';
loadYaml.mockReturnValueOnce(mockConfig);
await loadCustomConfig();
expect(logger.info).toHaveBeenCalledWith('Custom config file loaded:');
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2));
expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig);
});
});

View File

@@ -1,34 +1,17 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { EModelEndpoint, getEnabledEndpoints } = require('librechat-data-provider');
const loadAsyncEndpoints = require('./loadAsyncEndpoints');
const { config } = require('./EndpointService');
/**
* Load async endpoints and return a configuration object
* @function loadDefaultEndpointsConfig
* @param {Express.Request} req - The request object
* @returns {Promise<Object.<string, EndpointWithOrder>>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order.
*/
async function loadDefaultEndpointsConfig() {
const { google, gptPlugins } = await loadAsyncEndpoints();
async function loadDefaultEndpointsConfig(req) {
const { google, gptPlugins } = await loadAsyncEndpoints(req);
const { openAI, assistants, bingAI, anthropic, azureOpenAI, chatGPTBrowser } = config;
let enabledEndpoints = [
EModelEndpoint.openAI,
EModelEndpoint.assistants,
EModelEndpoint.azureOpenAI,
EModelEndpoint.google,
EModelEndpoint.bingAI,
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.gptPlugins,
EModelEndpoint.anthropic,
];
const endpointsEnv = process.env.ENDPOINTS || '';
if (endpointsEnv) {
enabledEndpoints = endpointsEnv
.split(',')
.filter((endpoint) => endpoint?.trim())
.map((endpoint) => endpoint.trim());
}
const enabledEndpoints = getEnabledEndpoints();
const endpointConfig = {
[EModelEndpoint.openAI]: openAI,

View File

@@ -24,7 +24,7 @@ async function loadDefaultModels(req) {
azure: useAzurePlugins,
plugins: true,
});
const assistant = await getOpenAIModels({ assistants: true });
const assistants = await getOpenAIModels({ assistants: true });
return {
[EModelEndpoint.openAI]: openAI,
@@ -34,7 +34,7 @@ async function loadDefaultModels(req) {
[EModelEndpoint.azureOpenAI]: azureOpenAI,
[EModelEndpoint.bingAI]: ['BingAI', 'Sydney'],
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowser,
[EModelEndpoint.assistants]: assistant,
[EModelEndpoint.assistants]: assistants,
};
}

View File

@@ -0,0 +1,32 @@
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { isEnabled } = require('~/server/utils');
const { saveConvo } = require('~/models');
const addTitle = async (req, { text, response, client }) => {
const { TITLE_CONVO = 'true' } = process.env ?? {};
if (!isEnabled(TITLE_CONVO)) {
return;
}
if (client.options.titleConvo === false) {
return;
}
// If the request was aborted, don't generate the title.
if (client.abortController.signal.aborted) {
return;
}
const titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
const title = await client.titleConvo({ text, responseText: response?.text });
await titleCache.set(key, title, 120000);
await saveConvo(req.user.id, {
conversationId: response.conversationId,
title,
});
};
module.exports = addTitle;

View File

@@ -1,9 +1,10 @@
const buildOptions = (endpoint, parsedBody) => {
const { modelLabel, promptPrefix, ...rest } = parsedBody;
const { modelLabel, promptPrefix, resendFiles, ...rest } = parsedBody;
const endpointOption = {
endpoint,
modelLabel,
promptPrefix,
resendFiles,
modelOptions: {
...rest,
},

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