Skip to content

Commit f14e34f

Browse files
authored
💫 feat: Config File & Custom Endpoints (danny-avila#1474)
* WIP(backend/api): custom endpoint * WIP(frontend/client): custom endpoint * chore: adjust typedefs for configs * refactor: use data-provider for cache keys and rename enums and custom endpoint for better clarity and compatibility * feat: loadYaml utility * refactor: rename back to from and proof-of-concept for creating schemas from user-defined defaults * refactor: remove custom endpoint from default endpointsConfig as it will be exclusively managed by yaml config * refactor(EndpointController): rename variables for clarity * feat: initial load custom config * feat(server/utils): add simple `isUserProvided` helper * chore(types): update TConfig type * refactor: remove custom endpoint handling from model services as will be handled by config, modularize fetching of models * feat: loadCustomConfig, loadConfigEndpoints, loadConfigModels * chore: reorganize server init imports, invoke loadCustomConfig * refactor(loadConfigEndpoints/Models): return each custom endpoint as standalone endpoint * refactor(Endpoint/ModelController): spread config values after default (temporary) * chore(client): fix type issues * WIP: first pass for multiple custom endpoints - add endpointType to Conversation schema - add update zod schemas for both convo/presets to allow non-EModelEndpoint value as endpoint (also using type assertion) - use `endpointType` value as `endpoint` where mapping to type is necessary using this field - use custom defined `endpoint` value and not type for mapping to modelsConfig - misc: add return type to `getDefaultEndpoint` - in `useNewConvo`, add the endpointType if it wasn't already added to conversation - EndpointsMenu: use user-defined endpoint name as Title in menu - TODO: custom icon via custom config, change unknown to robot icon * refactor(parseConvo): pass args as an object and change where used accordingly; chore: comment out 'create schema' code * chore: remove unused availableModels field in TConfig type * refactor(parseCompactConvo): pass args as an object and change where used accordingly * feat: chat through custom endpoint * chore(message/convoSchemas): avoid saving empty arrays * fix(BaseClient/saveMessageToDatabase): save endpointType * refactor(ChatRoute): show Spinner if endpointsQuery or modelsQuery are still loading, which is apparent with slow fetching of models/remote config on first serve * fix(useConversation): assign endpointType if it's missing * fix(SaveAsPreset): pass real endpoint and endpointType when saving Preset) * chore: recorganize types order for TConfig, add `iconURL` * feat: custom endpoint icon support: - use UnknownIcon in all icon contexts - add mistral and openrouter as known endpoints, and add their icons - iconURL support * fix(presetSchema): move endpointType to default schema definitions shared between convoSchema and defaults * refactor(Settings/OpenAI): remove legacy `isOpenAI` flag * fix(OpenAIClient): do not invoke abortCompletion on completion error * feat: add responseSender/label support for custom endpoints: - use defaultModelLabel field in endpointOption - add model defaults for custom endpoints in `getResponseSender` - add `useGetSender` hook which uses EndpointsQuery to determine `defaultModelLabel` - include defaultModelLabel from endpointConfig in custom endpoint client options - pass `endpointType` to `getResponseSender` * feat(OpenAIClient): use custom options from config file * refactor: rename `defaultModelLabel` to `modelDisplayLabel` * refactor(data-provider): separate concerns from `schemas` into `parsers`, `config`, and fix imports elsewhere * feat: `iconURL` and extract environment variables from custom endpoint config values * feat: custom config validation via zod schema, rename and move to `./projectRoot/librechat.yaml` * docs: custom config docs and examples * fix(OpenAIClient/mistral): mistral does not allow singular system message, also add `useChatCompletion` flag to use openai-node for title completions * fix(custom/initializeClient): extract env var and use `isUserProvided` function * Update librechat.example.yaml * feat(InputWithLabel): add className props, and forwardRef * fix(streamResponse): handle error edge case where either messages or convos query throws an error * fix(useSSE): handle errorHandler edge cases where error response is and is not properly formatted from API, especially when a conversationId is not yet provided, which ensures stream is properly closed on error * feat: user_provided keys for custom endpoints * fix(config/endpointSchema): do not allow default endpoint values in custom endpoint `name` * feat(loadConfigModels): extract env variables and optimize fetching models * feat: support custom endpoint iconURL for messages and Nav * feat(OpenAIClient): add/dropParams support * docs: update docs with default params, add/dropParams, and notes to use config file instead of `OPENAI_REVERSE_PROXY` * docs: update docs with additional notes * feat(maxTokensMap): add mistral models (32k context) * docs: update openrouter notes * Update ai_setup.md * docs(custom_config): add table of contents and fix note about custom name * docs(custom_config): reorder ToC * Update custom_config.md * Add note about `max_tokens` field in custom_config.md
1 parent 17453cb commit f14e34f

37 files changed

+724
-154
lines changed

app/clients/BaseClient.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ class BaseClient {
520520
await saveConvo(user, {
521521
conversationId: message.conversationId,
522522
endpoint: this.options.endpoint,
523+
endpointType: this.options.endpointType,
523524
...endpointOptions,
524525
});
525526
}

app/clients/OpenAIClient.js

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const OpenAI = require('openai');
22
const { HttpsProxyAgent } = require('https-proxy-agent');
3-
const { getResponseSender, EModelEndpoint } = require('librechat-data-provider');
3+
const { getResponseSender } = require('librechat-data-provider');
44
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
55
const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images');
66
const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('~/utils');
@@ -94,10 +94,23 @@ class OpenAIClient extends BaseClient {
9494
}
9595

9696
const { reverseProxyUrl: reverseProxy } = this.options;
97+
98+
if (
99+
!this.useOpenRouter &&
100+
reverseProxy &&
101+
reverseProxy.includes('https://openrouter.ai/api/v1')
102+
) {
103+
this.useOpenRouter = true;
104+
}
105+
97106
this.FORCE_PROMPT =
98107
isEnabled(OPENAI_FORCE_PROMPT) ||
99108
(reverseProxy && reverseProxy.includes('completions') && !reverseProxy.includes('chat'));
100109

110+
if (typeof this.options.forcePrompt === 'boolean') {
111+
this.FORCE_PROMPT = this.options.forcePrompt;
112+
}
113+
101114
if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) {
102115
this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model);
103116
this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL;
@@ -146,8 +159,10 @@ class OpenAIClient extends BaseClient {
146159
this.options.sender ??
147160
getResponseSender({
148161
model: this.modelOptions.model,
149-
endpoint: EModelEndpoint.openAI,
162+
endpoint: this.options.endpoint,
163+
endpointType: this.options.endpointType,
150164
chatGptLabel: this.options.chatGptLabel,
165+
modelDisplayLabel: this.options.modelDisplayLabel,
151166
});
152167

153168
this.userLabel = this.options.userLabel || 'User';
@@ -434,7 +449,7 @@ class OpenAIClient extends BaseClient {
434449
},
435450
opts.abortController || new AbortController(),
436451
);
437-
} else if (typeof opts.onProgress === 'function') {
452+
} else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) {
438453
reply = await this.chatCompletion({
439454
payload,
440455
clientOptions: opts,
@@ -530,6 +545,19 @@ class OpenAIClient extends BaseClient {
530545
return llm;
531546
}
532547

548+
/**
549+
* Generates a concise title for a conversation based on the user's input text and response.
550+
* Uses either specified method or starts with the OpenAI `functions` method (using LangChain).
551+
* If the `functions` method fails, it falls back to the `completion` method,
552+
* which involves sending a chat completion request with specific instructions for title generation.
553+
*
554+
* @param {Object} params - The parameters for the conversation title generation.
555+
* @param {string} params.text - The user's input.
556+
* @param {string} [params.responseText=''] - The AI's immediate response to the user.
557+
*
558+
* @returns {Promise<string | 'New Chat'>} A promise that resolves to the generated conversation title.
559+
* In case of failure, it will return the default title, "New Chat".
560+
*/
533561
async titleConvo({ text, responseText = '' }) {
534562
let title = 'New Chat';
535563
const convo = `||>User:
@@ -539,32 +567,25 @@ class OpenAIClient extends BaseClient {
539567

540568
const { OPENAI_TITLE_MODEL } = process.env ?? {};
541569

570+
const model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo';
571+
542572
const modelOptions = {
543-
model: OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo',
573+
// TODO: remove the gpt fallback and make it specific to endpoint
574+
model,
544575
temperature: 0.2,
545576
presence_penalty: 0,
546577
frequency_penalty: 0,
547578
max_tokens: 16,
548579
};
549580

550-
try {
551-
this.abortController = new AbortController();
552-
const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 });
553-
title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal });
554-
} catch (e) {
555-
if (e?.message?.toLowerCase()?.includes('abort')) {
556-
logger.debug('[OpenAIClient] Aborted title generation');
557-
return;
558-
}
559-
logger.error(
560-
'[OpenAIClient] There was an issue generating title with LangChain, trying the old method...',
561-
e,
562-
);
563-
modelOptions.model = OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo';
581+
const titleChatCompletion = async () => {
582+
modelOptions.model = model;
583+
564584
if (this.azure) {
565585
modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL ?? modelOptions.model;
566586
this.azureEndpoint = genAzureChatCompletion(this.azure, modelOptions.model);
567587
}
588+
568589
const instructionsPayload = [
569590
{
570591
role: 'system',
@@ -578,10 +599,38 @@ ${convo}
578599
];
579600

580601
try {
581-
title = (await this.sendPayload(instructionsPayload, { modelOptions })).replaceAll('"', '');
602+
title = (
603+
await this.sendPayload(instructionsPayload, { modelOptions, useChatCompletion: true })
604+
).replaceAll('"', '');
582605
} catch (e) {
583-
logger.error('[OpenAIClient] There was another issue generating the title', e);
606+
logger.error(
607+
'[OpenAIClient] There was an issue generating the title with the completion method',
608+
e,
609+
);
610+
}
611+
};
612+
613+
if (this.options.titleMethod === 'completion') {
614+
await titleChatCompletion();
615+
logger.debug('[OpenAIClient] Convo Title: ' + title);
616+
return title;
617+
}
618+
619+
try {
620+
this.abortController = new AbortController();
621+
const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 });
622+
title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal });
623+
} catch (e) {
624+
if (e?.message?.toLowerCase()?.includes('abort')) {
625+
logger.debug('[OpenAIClient] Aborted title generation');
626+
return;
584627
}
628+
logger.error(
629+
'[OpenAIClient] There was an issue generating title with LangChain, trying completion method...',
630+
e,
631+
);
632+
633+
await titleChatCompletion();
585634
}
586635

587636
logger.debug('[OpenAIClient] Convo Title: ' + title);
@@ -593,8 +642,11 @@ ${convo}
593642
let context = messagesToRefine;
594643
let prompt;
595644

645+
// TODO: remove the gpt fallback and make it specific to endpoint
596646
const { OPENAI_SUMMARY_MODEL = 'gpt-3.5-turbo' } = process.env ?? {};
597-
const maxContextTokens = getModelMaxTokens(OPENAI_SUMMARY_MODEL) ?? 4095;
647+
const model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL;
648+
const maxContextTokens = getModelMaxTokens(model) ?? 4095;
649+
598650
// 3 tokens for the assistant label, and 98 for the summarizer prompt (101)
599651
let promptBuffer = 101;
600652

@@ -644,7 +696,7 @@ ${convo}
644696
logger.debug('[OpenAIClient] initialPromptTokens', initialPromptTokens);
645697

646698
const llm = this.initializeLLM({
647-
model: OPENAI_SUMMARY_MODEL,
699+
model,
648700
temperature: 0.2,
649701
context: 'summary',
650702
tokenBuffer: initialPromptTokens,
@@ -719,7 +771,9 @@ ${convo}
719771
if (!abortController) {
720772
abortController = new AbortController();
721773
}
722-
const modelOptions = { ...this.modelOptions };
774+
775+
let modelOptions = { ...this.modelOptions };
776+
723777
if (typeof onProgress === 'function') {
724778
modelOptions.stream = true;
725779
}
@@ -779,6 +833,27 @@ ${convo}
779833
...opts,
780834
});
781835

836+
/* hacky fix for Mistral AI API not allowing a singular system message in payload */
837+
if (opts.baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) {
838+
const { messages } = modelOptions;
839+
if (messages.length === 1 && messages[0].role === 'system') {
840+
modelOptions.messages[0].role = 'user';
841+
}
842+
}
843+
844+
if (this.options.addParams && typeof this.options.addParams === 'object') {
845+
modelOptions = {
846+
...modelOptions,
847+
...this.options.addParams,
848+
};
849+
}
850+
851+
if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
852+
this.options.dropParams.forEach((param) => {
853+
delete modelOptions[param];
854+
});
855+
}
856+
782857
let UnexpectedRoleError = false;
783858
if (modelOptions.stream) {
784859
const stream = await openai.beta.chat.completions

cache/getCustomConfig.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { CacheKeys } = require('librechat-data-provider');
2+
const loadCustomConfig = require('~/server/services/Config/loadCustomConfig');
3+
const getLogStores = require('./getLogStores');
4+
5+
/**
6+
* Retrieves the configuration object
7+
* @function getCustomConfig */
8+
async function getCustomConfig() {
9+
const cache = getLogStores(CacheKeys.CONFIG_STORE);
10+
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG);
11+
12+
if (!customConfig) {
13+
customConfig = await loadCustomConfig();
14+
}
15+
16+
if (!customConfig) {
17+
return null;
18+
}
19+
20+
return customConfig;
21+
}
22+
23+
module.exports = getCustomConfig;

cache/getLogStores.js

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
const Keyv = require('keyv');
2-
const keyvMongo = require('./keyvMongo');
3-
const keyvRedis = require('./keyvRedis');
4-
const { CacheKeys } = require('~/common/enums');
5-
const { math, isEnabled } = require('~/server/utils');
2+
const { CacheKeys } = require('librechat-data-provider');
63
const { logFile, violationFile } = require('./keyvFiles');
4+
const { math, isEnabled } = require('~/server/utils');
5+
const keyvRedis = require('./keyvRedis');
6+
const keyvMongo = require('./keyvMongo');
7+
78
const { BAN_DURATION, USE_REDIS } = process.env ?? {};
89

910
const duration = math(BAN_DURATION, 7200000);
@@ -20,10 +21,10 @@ const pending_req = isEnabled(USE_REDIS)
2021

2122
const config = isEnabled(USE_REDIS)
2223
? new Keyv({ store: keyvRedis })
23-
: new Keyv({ namespace: CacheKeys.CONFIG });
24+
: new Keyv({ namespace: CacheKeys.CONFIG_STORE });
2425

2526
const namespaces = {
26-
config,
27+
[CacheKeys.CONFIG_STORE]: config,
2728
pending_req,
2829
ban: new Keyv({ store: keyvMongo, namespace: 'bans', ttl: duration }),
2930
general: new Keyv({ store: logFile, namespace: 'violations' }),
@@ -39,19 +40,15 @@ const namespaces = {
3940
* Returns the keyv cache specified by type.
4041
* If an invalid type is passed, an error will be thrown.
4142
*
42-
* @module getLogStores
43-
* @requires keyv - a simple key-value storage that allows you to easily switch out storage adapters.
44-
* @requires keyvFiles - a module that includes the logFile and violationFile.
45-
*
46-
* @param {string} type - The type of violation, which can be 'concurrent', 'message_limit', 'registrations' or 'logins'.
47-
* @returns {Keyv} - If a valid type is passed, returns an object containing the logs for violations of the specified type.
48-
* @throws Will throw an error if an invalid violation type is passed.
43+
* @param {string} key - The key for the namespace to access
44+
* @returns {Keyv} - If a valid key is passed, returns an object containing the cache store of the specified key.
45+
* @throws Will throw an error if an invalid key is passed.
4946
*/
50-
const getLogStores = (type) => {
51-
if (!type || !namespaces[type]) {
52-
throw new Error(`Invalid store type: ${type}`);
47+
const getLogStores = (key) => {
48+
if (!key || !namespaces[key]) {
49+
throw new Error(`Invalid store key: ${key}`);
5350
}
54-
return namespaces[type];
51+
return namespaces[key];
5552
};
5653

5754
module.exports = getLogStores;

common/enums.js

Lines changed: 0 additions & 17 deletions
This file was deleted.

models/schema/convoSchema.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,29 @@ const convoSchema = mongoose.Schema(
1818
user: {
1919
type: String,
2020
index: true,
21-
// default: null,
2221
},
2322
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
2423
// google only
25-
examples: [{ type: mongoose.Schema.Types.Mixed }],
24+
examples: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
2625
agentOptions: {
2726
type: mongoose.Schema.Types.Mixed,
28-
// default: null,
2927
},
3028
...conversationPreset,
3129
// for bingAI only
3230
bingConversationId: {
3331
type: String,
34-
// default: null,
3532
},
3633
jailbreakConversationId: {
3734
type: String,
38-
// default: null,
3935
},
4036
conversationSignature: {
4137
type: String,
42-
// default: null,
4338
},
4439
clientId: {
4540
type: String,
46-
// default: null,
4741
},
4842
invocationId: {
4943
type: Number,
50-
// default: 1,
5144
},
5245
},
5346
{ timestamps: true },

models/schema/defaults.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const conversationPreset = {
55
default: null,
66
required: true,
77
},
8+
endpointType: {
9+
type: String,
10+
},
811
// for azureOpenAI, openAI, chatGPTBrowser only
912
model: {
1013
type: String,
@@ -95,7 +98,6 @@ const agentOptions = {
9598
// default: null,
9699
required: false,
97100
},
98-
// for google only
99101
modelLabel: {
100102
type: String,
101103
// default: null,

0 commit comments

Comments
 (0)