Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💫 feat: Config File & Custom Endpoints #1474

Merged
merged 60 commits into from
Jan 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
0b4a88a
WIP(backend/api): custom endpoint
danny-avila Dec 30, 2023
eaa1409
WIP(frontend/client): custom endpoint
danny-avila Dec 30, 2023
04fa376
chore: adjust typedefs for configs
danny-avila Dec 31, 2023
6fcfca0
refactor: use data-provider for cache keys and rename enums and custo…
danny-avila Dec 31, 2023
98cda57
feat: loadYaml utility
danny-avila Dec 31, 2023
33df5eb
refactor: rename back to from and proof-of-concept for creating sch…
danny-avila Dec 31, 2023
01f1060
refactor: remove custom endpoint from default endpointsConfig as it w…
danny-avila Dec 31, 2023
3b8c038
refactor(EndpointController): rename variables for clarity
danny-avila Dec 31, 2023
3e5cb1b
feat: initial load custom config
danny-avila Dec 31, 2023
135debf
feat(server/utils): add simple `isUserProvided` helper
danny-avila Dec 31, 2023
b3cc02c
chore(types): update TConfig type
danny-avila Dec 31, 2023
637d57c
refactor: remove custom endpoint handling from model services as will…
danny-avila Dec 31, 2023
73ded7b
feat: loadCustomConfig, loadConfigEndpoints, loadConfigModels
danny-avila Dec 31, 2023
425c8d8
chore: reorganize server init imports, invoke loadCustomConfig
danny-avila Dec 31, 2023
e608f6d
refactor(loadConfigEndpoints/Models): return each custom endpoint as …
danny-avila Dec 31, 2023
62bb326
refactor(Endpoint/ModelController): spread config values after defaul…
danny-avila Dec 31, 2023
ba9d068
chore(client): fix type issues
danny-avila Dec 31, 2023
ce69401
WIP: first pass for multiple custom endpoints
danny-avila Jan 1, 2024
e4c0cf4
refactor(parseConvo): pass args as an object and change where used ac…
danny-avila Jan 1, 2024
fe50b09
chore: remove unused availableModels field in TConfig type
danny-avila Jan 1, 2024
a18d851
refactor(parseCompactConvo): pass args as an object and change where …
danny-avila Jan 1, 2024
de8bba0
feat: chat through custom endpoint
danny-avila Jan 1, 2024
ba43f78
chore(message/convoSchemas): avoid saving empty arrays
danny-avila Jan 1, 2024
e93b8d1
fix(BaseClient/saveMessageToDatabase): save endpointType
danny-avila Jan 1, 2024
050d948
refactor(ChatRoute): show Spinner if endpointsQuery or modelsQuery ar…
danny-avila Jan 1, 2024
13d2be6
fix(useConversation): assign endpointType if it's missing
danny-avila Jan 1, 2024
ed3f232
fix(SaveAsPreset): pass real endpoint and endpointType when saving Pr…
danny-avila Jan 1, 2024
414359f
chore: recorganize types order for TConfig, add `iconURL`
danny-avila Jan 1, 2024
e8e25ed
feat: custom endpoint icon support:
danny-avila Jan 1, 2024
16bb96f
fix(presetSchema): move endpointType to default schema definitions sh…
danny-avila Jan 1, 2024
1a9640d
refactor(Settings/OpenAI): remove legacy `isOpenAI` flag
danny-avila Jan 1, 2024
e47b31f
fix(OpenAIClient): do not invoke abortCompletion on completion error
danny-avila Jan 1, 2024
2821c62
feat: add responseSender/label support for custom endpoints:
danny-avila Jan 2, 2024
d12e5a0
feat(OpenAIClient): use custom options from config file
danny-avila Jan 2, 2024
e90e835
refactor: rename `defaultModelLabel` to `modelDisplayLabel`
danny-avila Jan 2, 2024
0a2bbb5
refactor(data-provider): separate concerns from `schemas` into `parse…
danny-avila Jan 2, 2024
df00c28
feat: `iconURL` and extract environment variables from custom endpoin…
danny-avila Jan 2, 2024
5e97b71
feat: custom config validation via zod schema, rename and move to `./…
danny-avila Jan 2, 2024
dbc0e4b
docs: custom config docs and examples
danny-avila Jan 2, 2024
3ed91f6
fix(OpenAIClient/mistral): mistral does not allow singular system mes…
danny-avila Jan 2, 2024
8910e4f
fix(custom/initializeClient): extract env var and use `isUserProvided…
danny-avila Jan 2, 2024
9dbf63b
Update librechat.example.yaml
danny-avila Jan 2, 2024
abe25b6
feat(InputWithLabel): add className props, and forwardRef
danny-avila Jan 2, 2024
e3b7b06
fix(streamResponse): handle error edge case where either messages or …
danny-avila Jan 2, 2024
204cc1e
fix(useSSE): handle errorHandler edge cases where error response is a…
danny-avila Jan 2, 2024
00baf21
feat: user_provided keys for custom endpoints
danny-avila Jan 2, 2024
3d909cd
fix(config/endpointSchema): do not allow default endpoint values in c…
danny-avila Jan 2, 2024
a301b2e
feat(loadConfigModels): extract env variables and optimize fetching m…
danny-avila Jan 2, 2024
e0509f3
feat: support custom endpoint iconURL for messages and Nav
danny-avila Jan 2, 2024
f8a8b42
feat(OpenAIClient): add/dropParams support
danny-avila Jan 2, 2024
84c786b
docs: update docs with default params, add/dropParams, and notes to u…
danny-avila Jan 3, 2024
facd6f9
docs: update docs with additional notes
danny-avila Jan 3, 2024
8d59455
feat(maxTokensMap): add mistral models (32k context)
danny-avila Jan 3, 2024
80ab834
docs: update openrouter notes
danny-avila Jan 3, 2024
3a3d249
Update ai_setup.md
danny-avila Jan 3, 2024
1344c79
docs(custom_config): add table of contents and fix note about custom …
danny-avila Jan 3, 2024
13a5237
Merge branch 'custom-endpoint' of https://github.com/danny-avila/Libr…
danny-avila Jan 3, 2024
5cac29f
docs(custom_config): reorder ToC
danny-avila Jan 3, 2024
8768885
Update custom_config.md
danny-avila Jan 3, 2024
eada8c1
Add note about `max_tokens` field in custom_config.md
danny-avila Jan 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ bower_components/
.floo
.flooignore

#config file
librechat.yaml

# Environment
.npmrc
.env*
Expand Down
1 change: 1 addition & 0 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ class BaseClient {
await saveConvo(user, {
conversationId: message.conversationId,
endpoint: this.options.endpoint,
endpointType: this.options.endpointType,
...endpointOptions,
});
}
Expand Down
122 changes: 98 additions & 24 deletions api/app/clients/OpenAIClient.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { getResponseSender, EModelEndpoint } = require('librechat-data-provider');
const { getResponseSender } = require('librechat-data-provider');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images');
const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('~/utils');
Expand Down Expand Up @@ -94,10 +94,23 @@ class OpenAIClient extends BaseClient {
}

const { reverseProxyUrl: reverseProxy } = this.options;

if (
!this.useOpenRouter &&
reverseProxy &&
reverseProxy.includes('https://openrouter.ai/api/v1')
) {
this.useOpenRouter = true;
}

this.FORCE_PROMPT =
isEnabled(OPENAI_FORCE_PROMPT) ||
(reverseProxy && reverseProxy.includes('completions') && !reverseProxy.includes('chat'));

if (typeof this.options.forcePrompt === 'boolean') {
this.FORCE_PROMPT = this.options.forcePrompt;
}

if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) {
this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model);
this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL;
Expand Down Expand Up @@ -146,8 +159,10 @@ class OpenAIClient extends BaseClient {
this.options.sender ??
getResponseSender({
model: this.modelOptions.model,
endpoint: EModelEndpoint.openAI,
endpoint: this.options.endpoint,
endpointType: this.options.endpointType,
chatGptLabel: this.options.chatGptLabel,
modelDisplayLabel: this.options.modelDisplayLabel,
});

this.userLabel = this.options.userLabel || 'User';
Expand Down Expand Up @@ -434,7 +449,7 @@ class OpenAIClient extends BaseClient {
},
opts.abortController || new AbortController(),
);
} else if (typeof opts.onProgress === 'function') {
} else if (typeof opts.onProgress === 'function' || this.options.useChatCompletion) {
reply = await this.chatCompletion({
payload,
clientOptions: opts,
Expand Down Expand Up @@ -530,6 +545,19 @@ class OpenAIClient extends BaseClient {
return llm;
}

/**
* Generates a concise title for a conversation based on the user's input text and response.
* Uses either specified method or starts with the OpenAI `functions` method (using LangChain).
* If the `functions` method fails, it falls back to the `completion` method,
* which involves sending a chat completion request with specific instructions for title generation.
*
* @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 = `||>User:
Expand All @@ -539,32 +567,25 @@ class OpenAIClient extends BaseClient {

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

const model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo';

const modelOptions = {
model: OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo',
// TODO: remove the gpt fallback and make it specific to endpoint
model,
temperature: 0.2,
presence_penalty: 0,
frequency_penalty: 0,
max_tokens: 16,
};

try {
this.abortController = new AbortController();
const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 });
title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal });
} catch (e) {
if (e?.message?.toLowerCase()?.includes('abort')) {
logger.debug('[OpenAIClient] Aborted title generation');
return;
}
logger.error(
'[OpenAIClient] There was an issue generating title with LangChain, trying the old method...',
e,
);
modelOptions.model = OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo';
const titleChatCompletion = async () => {
modelOptions.model = model;

if (this.azure) {
modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL ?? modelOptions.model;
this.azureEndpoint = genAzureChatCompletion(this.azure, modelOptions.model);
}

const instructionsPayload = [
{
role: 'system',
Expand All @@ -578,10 +599,38 @@ ${convo}
];

try {
title = (await this.sendPayload(instructionsPayload, { modelOptions })).replaceAll('"', '');
title = (
await this.sendPayload(instructionsPayload, { modelOptions, useChatCompletion: true })
).replaceAll('"', '');
} catch (e) {
logger.error('[OpenAIClient] There was another issue generating the title', e);
logger.error(
'[OpenAIClient] There was an issue generating the title with the completion method',
e,
);
}
};

if (this.options.titleMethod === 'completion') {
await titleChatCompletion();
logger.debug('[OpenAIClient] Convo Title: ' + title);
return title;
}

try {
this.abortController = new AbortController();
const llm = this.initializeLLM({ ...modelOptions, context: 'title', tokenBuffer: 150 });
title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal });
} catch (e) {
if (e?.message?.toLowerCase()?.includes('abort')) {
logger.debug('[OpenAIClient] Aborted title generation');
return;
}
logger.error(
'[OpenAIClient] There was an issue generating title with LangChain, trying completion method...',
e,
);

await titleChatCompletion();
}

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

// TODO: remove the gpt fallback and make it specific to endpoint
const { OPENAI_SUMMARY_MODEL = 'gpt-3.5-turbo' } = process.env ?? {};
const maxContextTokens = getModelMaxTokens(OPENAI_SUMMARY_MODEL) ?? 4095;
const model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL;
const maxContextTokens = getModelMaxTokens(model) ?? 4095;

// 3 tokens for the assistant label, and 98 for the summarizer prompt (101)
let promptBuffer = 101;

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

const llm = this.initializeLLM({
model: OPENAI_SUMMARY_MODEL,
model,
temperature: 0.2,
context: 'summary',
tokenBuffer: initialPromptTokens,
Expand Down Expand Up @@ -719,7 +771,9 @@ ${convo}
if (!abortController) {
abortController = new AbortController();
}
const modelOptions = { ...this.modelOptions };

let modelOptions = { ...this.modelOptions };

if (typeof onProgress === 'function') {
modelOptions.stream = true;
}
Expand Down Expand Up @@ -779,6 +833,27 @@ ${convo}
...opts,
});

/* hacky fix for Mistral AI API not allowing a singular system message in payload */
if (opts.baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) {
const { messages } = modelOptions;
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,
};
}

if (this.options.dropParams && Array.isArray(this.options.dropParams)) {
this.options.dropParams.forEach((param) => {
delete modelOptions[param];
});
}

let UnexpectedRoleError = false;
if (modelOptions.stream) {
const stream = await openai.beta.chat.completions
Expand Down Expand Up @@ -859,7 +934,6 @@ ${convo}
(err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason'))
) {
logger.error('[OpenAIClient] Known OpenAI error:', err);
await abortController.abortCompletion();
return intermediateReply;
} else if (err instanceof OpenAI.APIError) {
if (intermediateReply) {
Expand Down
23 changes: 23 additions & 0 deletions api/cache/getCustomConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const { CacheKeys } = require('librechat-data-provider');
const loadCustomConfig = require('~/server/services/Config/loadCustomConfig');
const getLogStores = require('./getLogStores');

/**
* Retrieves the configuration object
* @function getCustomConfig */
async function getCustomConfig() {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG);

if (!customConfig) {
customConfig = await loadCustomConfig();
}

if (!customConfig) {
return null;
}

return customConfig;
}

module.exports = getCustomConfig;
31 changes: 14 additions & 17 deletions api/cache/getLogStores.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
const Keyv = require('keyv');
const keyvMongo = require('./keyvMongo');
const keyvRedis = require('./keyvRedis');
const { CacheKeys } = require('~/common/enums');
const { math, isEnabled } = require('~/server/utils');
const { CacheKeys } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { math, isEnabled } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo');

const { BAN_DURATION, USE_REDIS } = process.env ?? {};

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

const config = isEnabled(USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: CacheKeys.CONFIG });
: new Keyv({ namespace: CacheKeys.CONFIG_STORE });

const namespaces = {
config,
[CacheKeys.CONFIG_STORE]: config,
pending_req,
ban: new Keyv({ store: keyvMongo, namespace: 'bans', ttl: duration }),
general: new Keyv({ store: logFile, namespace: 'violations' }),
Expand All @@ -39,19 +40,15 @@ const namespaces = {
* Returns the keyv cache specified by type.
* If an invalid type is passed, an error will be thrown.
*
* @module getLogStores
* @requires keyv - a simple key-value storage that allows you to easily switch out storage adapters.
* @requires keyvFiles - a module that includes the logFile and violationFile.
*
* @param {string} type - The type of violation, which can be 'concurrent', 'message_limit', 'registrations' or 'logins'.
* @returns {Keyv} - If a valid type is passed, returns an object containing the logs for violations of the specified type.
* @throws Will throw an error if an invalid violation type is passed.
* @param {string} key - The key for the namespace to access
* @returns {Keyv} - If a valid key is passed, returns an object containing the cache store of the specified key.
* @throws Will throw an error if an invalid key is passed.
*/
const getLogStores = (type) => {
if (!type || !namespaces[type]) {
throw new Error(`Invalid store type: ${type}`);
const getLogStores = (key) => {
if (!key || !namespaces[key]) {
throw new Error(`Invalid store key: ${key}`);
}
return namespaces[type];
return namespaces[key];
};

module.exports = getLogStores;
17 changes: 0 additions & 17 deletions api/common/enums.js

This file was deleted.

9 changes: 1 addition & 8 deletions api/models/schema/convoSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,29 @@ const convoSchema = mongoose.Schema(
user: {
type: String,
index: true,
// default: null,
},
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
// google only
examples: [{ type: mongoose.Schema.Types.Mixed }],
examples: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
agentOptions: {
type: mongoose.Schema.Types.Mixed,
// default: null,
},
...conversationPreset,
// for bingAI only
bingConversationId: {
type: String,
// default: null,
},
jailbreakConversationId: {
type: String,
// default: null,
},
conversationSignature: {
type: String,
// default: null,
},
clientId: {
type: String,
// default: null,
},
invocationId: {
type: Number,
// default: 1,
},
},
{ timestamps: true },
Expand Down
4 changes: 3 additions & 1 deletion api/models/schema/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const conversationPreset = {
default: null,
required: true,
},
endpointType: {
type: String,
},
// for azureOpenAI, openAI, chatGPTBrowser only
model: {
type: String,
Expand Down Expand Up @@ -95,7 +98,6 @@ const agentOptions = {
// default: null,
required: false,
},
// for google only
modelLabel: {
type: String,
// default: null,
Expand Down
Loading