Skip to content

Commit

Permalink
refactor: Encrypt & Expire User Provided Keys, feat: Rate Limiting (d…
Browse files Browse the repository at this point in the history
…anny-avila#874)

* docs: make_your_own.md formatting fix for mkdocs

* feat: add express-mongo-sanitize
feat: add login/registration rate limiting

* chore: remove unnecessary console log

* wip: remove token handling from localStorage to encrypted DB solution

* refactor: minor change to UserService

* fix mongo query and add keys route to server

* fix backend controllers and simplify schema/crud

* refactor: rename token to key to separate from access/refresh tokens, setTokenDialog -> setKeyDialog

* refactor(schemas): TEndpointOption token -> key

* refactor(api): use new encrypted key retrieval system

* fix(SetKeyDialog): fix key prop error

* fix(abortMiddleware): pass random UUID if messageId is not generated yet for proper error display on frontend

* fix(getUserKey): wrong prop passed in arg, adds error handling

* fix: prevent message without conversationId from saving to DB, prevents branching on the frontend to a new top-level branch

* refactor: change wording of multiple display messages

* refactor(checkExpiry -> checkUserKeyExpiry): move to UserService file

* fix: type imports from common

* refactor(SubmitButton): convert to TS

* refactor(key.ts): change localStorage map key name

* refactor: add new custom tailwind classes to better match openAI colors

* chore: remove unnecessary warning and catch ScreenShot error

* refactor: move userKey frontend logic to hooks and remove use of localStorage and instead query the DB

* refactor: invalidate correct query key, memoize userKey hook, conditionally render SetKeyDialog to avoid unnecessary calls, refactor SubmitButton props and useEffect for showing 'provide key first'

* fix(SetKeyDialog): use enum-like object for expiry values
feat(Dropdown): add optionsClassName to dynamically change dropdown options container classes

* fix: handle edge case where user had provided a key but the server changes to env variable for keys

* refactor(OpenAI/titleConvo): move titling to client to retain authorized credentials in message lifecycle for titling

* fix(azure): handle user_provided keys correctly for azure

* feat: send user Id to OpenAI to differentiate users in completion requests

* refactor(OpenAI/titleConvo): adding tokens helps minimize LLM from using the language in title response

* feat: add delete endpoint for keys

* chore: remove throttling of title

* feat: add 'Data controls' to Settings, add 'Revoke' keys feature in Key Dialog and Data controls

* refactor: reorganize PluginsClient files in langchain format

* feat: use langchain for titling convos

* chore: cleanup titling convo, with fallback to original method, escape braces, use only snippet for language detection

* refactor: move helper functions to appropriate langchain folders for reusability

* fix: userProvidesKey handling for gptPlugins

* fix: frontend handling of plugins key

* chore: cleanup logging and ts-ignore SSE

* fix: forwardRef misuse in DangerButton

* fix(GoogleConfig/FileUpload): localize errors and simplify validation with zod

* fix: cleanup google logging and fix user provided key handling

* chore: remove titling from google

* chore: removing logging from browser endpoint

* wip: fix menu flicker

* feat: useLocalStorage hook

* feat: add Tooltip for UI

* refactor(EndpointMenu): utilize Tooltip and useLocalStorage, remove old 'New Chat' slide-over

* fix(e2e): use testId for endpoint menu trigger

* chore: final touches to EndpointMenu before future refactor to declutter component

* refactor(localization): change select endpoint to open menu and add translations

* chore: add final prop to error message response

* ci: minor edits to facilitate testing

* ci: new e2e test which tests for new key setting/revoking features
  • Loading branch information
danny-avila authored Sep 6, 2023
1 parent 64f1557 commit 4ca43fb
Show file tree
Hide file tree
Showing 122 changed files with 1,930 additions and 963 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ APP_TITLE=LibreChat
HOST=localhost
PORT=3080

# Login and registration rate limiting.

LOGIN_MAX=7 # The max amount of logins allowed per IP per LOGIN_WINDOW
LOGIN_WINDOW=5 # in minutes, determines how long an IP is banned for after LOGIN_MAX logins
REGISTER_MAX=5 # The max amount of registrations allowed per IP per REGISTER_WINDOW
REGISTER_WINDOW=60 # in minutes, determines how long an IP is banned for after REGISTER_MAX registrations

# Change this to proxy any API request.
# It's useful if your machine has difficulty calling the original API server.
# PROXY=
Expand Down
19 changes: 16 additions & 3 deletions api/app/bingai.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require('dotenv').config();
const { KeyvFile } = require('keyv-file');
const { getUserKey, checkUserKeyExpiry } = require('../server/services/UserService');

const askBing = async ({
text,
Expand All @@ -13,9 +14,21 @@ const askBing = async ({
clientId,
invocationId,
toneStyle,
token,
key: expiresAt,
onProgress,
userId,
}) => {
const isUserProvided = process.env.BINGAI_TOKEN === 'user_provided';

let key = null;
if (expiresAt && isUserProvided) {
checkUserKeyExpiry(
expiresAt,
'Your BingAI Cookies have expired. Please provide your cookies again.',
);
key = await getUserKey({ userId, name: 'bingAI' });
}

const { BingAIClient } = await import('@waylaidwanderer/chatgpt-api');
const store = {
store: new KeyvFile({ filename: './data/cache.json' }),
Expand All @@ -24,9 +37,9 @@ const askBing = async ({
const bingAIClient = new BingAIClient({
// "_U" cookie from bing.com
// userToken:
// process.env.BINGAI_TOKEN == 'user_provided' ? token : process.env.BINGAI_TOKEN ?? null,
// isUserProvided ? key : process.env.BINGAI_TOKEN ?? null,
// If the above doesn't work, provide all your cookies as a string instead
cookies: process.env.BINGAI_TOKEN == 'user_provided' ? token : process.env.BINGAI_TOKEN ?? null,
cookies: isUserProvided ? key : process.env.BINGAI_TOKEN ?? null,
debug: false,
cache: store,
host: process.env.BINGAI_HOST || null,
Expand Down
23 changes: 16 additions & 7 deletions api/app/chatgpt-browser.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
require('dotenv').config();
const { KeyvFile } = require('keyv-file');
const { getUserKey, checkUserKeyExpiry } = require('../server/services/UserService');

const browserClient = async ({
text,
parentMessageId,
conversationId,
model,
token,
key: expiresAt,
onProgress,
onEventMessage,
abortController,
userId,
}) => {
const isUserProvided = process.env.CHATGPT_TOKEN === 'user_provided';

let key = null;
if (expiresAt && isUserProvided) {
checkUserKeyExpiry(
expiresAt,
'Your ChatGPT Access Token has expired. Please provide your token again.',
);
key = await getUserKey({ userId, name: 'chatGPTBrowser' });
}

const { ChatGPTBrowserClient } = await import('@waylaidwanderer/chatgpt-api');
const store = {
store: new KeyvFile({ filename: './data/cache.json' }),
Expand All @@ -20,13 +32,12 @@ const browserClient = async ({
const clientOptions = {
// Warning: This will expose your access token to a third party. Consider the risks before using this.
reverseProxyUrl:
process.env.CHATGPT_REVERSE_PROXY || 'https://ai.fakeopen.com/api/conversation',
process.env.CHATGPT_REVERSE_PROXY ?? 'https://ai.fakeopen.com/api/conversation',
// Access token from https://chat.openai.com/api/auth/session
accessToken:
process.env.CHATGPT_TOKEN == 'user_provided' ? token : process.env.CHATGPT_TOKEN ?? null,
accessToken: isUserProvided ? key : process.env.CHATGPT_TOKEN ?? null,
model: model,
debug: false,
proxy: process.env.PROXY || null,
proxy: process.env.PROXY ?? null,
user: userId,
};

Expand All @@ -37,8 +48,6 @@ const browserClient = async ({
options = { ...options, parentMessageId, conversationId };
}

console.log('gptBrowser clientOptions', clientOptions);

if (parentMessageId === '00000000-0000-0000-0000-000000000000') {
delete options.conversationId;
}
Expand Down
12 changes: 10 additions & 2 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ const TextStream = require('./TextStream');
const { RecursiveCharacterTextSplitter } = require('langchain/text_splitter');
const { ChatOpenAI } = require('langchain/chat_models/openai');
const { loadSummarizationChain } = require('langchain/chains');
const { refinePrompt } = require('./prompts/refinePrompt');
const { getConvo, getMessages, saveMessage, updateMessage, saveConvo } = require('../../models');
const { addSpaceIfNeeded } = require('../../server/utils');
const { refinePrompt } = require('./prompts');

class BaseClient {
constructor(apiKey, options = {}) {
Expand Down Expand Up @@ -55,6 +55,7 @@ class BaseClient {

const { isEdited, isContinued } = opts;
const user = opts.user ?? null;
this.user = user;
const saveOptions = this.getSaveOptions();
this.abortController = opts.abortController ?? new AbortController();
const conversationId = opts.conversationId ?? crypto.randomUUID();
Expand Down Expand Up @@ -407,7 +408,6 @@ class BaseClient {

const { generation = '' } = opts;

this.user = user;
// It's not necessary to push to currentMessages
// depending on subclass implementation of handling messages
// When this is an edit, all messages are already in currentMessages, both user and response
Expand Down Expand Up @@ -600,6 +600,14 @@ class BaseClient {
// Sum the number of tokens in all properties and add `tokensPerMessage` for metadata
return propertyTokenCounts.reduce((a, b) => a + b, tokensPerMessage);
}

async sendPayload(payload, opts = {}) {
if (opts && typeof opts === 'object') {
this.setOptions(opts);
}

return await this.sendCompletion(payload, opts);
}
}

module.exports = BaseClient;
6 changes: 4 additions & 2 deletions api/app/clients/GoogleClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class GoogleClient extends BaseClient {

jwtClient.authorize((err) => {
if (err) {
console.log(err);
console.error('Error: jwtClient failed to authorize');
console.error(err.message);
throw err;
}
});
Expand Down Expand Up @@ -247,7 +248,8 @@ class GoogleClient extends BaseClient {
console.debug(result);
}
} catch (err) {
console.error(err);
console.error('Error: failed to send completion to Google');
console.error(err.message);
}

if (!blocked) {
Expand Down
64 changes: 63 additions & 1 deletion api/app/clients/OpenAIClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const {
get_encoding: getEncoding,
} = require('@dqbd/tiktoken');
const { maxTokensMap, genAzureChatCompletion } = require('../../utils');
const { runTitleChain } = require('./chains');
const { createLLM } = require('./llm');

// Cache to store Tiktoken instances
const tokenizersCache = {};
Expand Down Expand Up @@ -105,6 +107,7 @@ class OpenAIClient extends BaseClient {

if (this.options.reverseProxyUrl) {
this.completionsUrl = this.options.reverseProxyUrl;
this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0];
} else if (isChatGptModel) {
this.completionsUrl = 'https://api.openai.com/v1/chat/completions';
} else {
Expand All @@ -116,7 +119,7 @@ class OpenAIClient extends BaseClient {
}

if (this.azureEndpoint && this.options.debug) {
console.debug(`Using Azure endpoint: ${this.azureEndpoint}`, this.azure);
console.debug('Using Azure endpoint');
}

return this;
Expand Down Expand Up @@ -315,6 +318,7 @@ class OpenAIClient extends BaseClient {
let reply = '';
let result = null;
let streamResult = null;
this.modelOptions.user = this.user;
if (typeof opts.onProgress === 'function') {
await this.getCompletion(
payload,
Expand Down Expand Up @@ -373,6 +377,64 @@ class OpenAIClient extends BaseClient {
content: response.text,
});
}

async titleConvo({ text, responseText = '' }) {
let title = 'New Chat';
const convo = `||>User:
"${text}"
||>Response:
"${JSON.stringify(responseText)}"`;

const modelOptions = {
model: 'gpt-3.5-turbo-0613',
temperature: 0.2,
presence_penalty: 0,
frequency_penalty: 0,
max_tokens: 16,
};

const configOptions = {};

if (this.langchainProxy) {
configOptions.basePath = this.langchainProxy;
}

try {
const llm = createLLM({
modelOptions,
configOptions,
openAIApiKey: this.apiKey,
azure: this.azure,
});

title = await runTitleChain({ llm, text, convo });
} catch (e) {
console.error(e.message);
console.log('There was an issue generating title with LangChain, trying the old method...');
modelOptions.model = 'gpt-3.5-turbo';
const instructionsPayload = [
{
role: 'system',
content: `Detect user language and write in the same language an extremely concise title for this conversation, which you must accurately detect.
Write in the detected language. Title in 5 Words or Less. No Punctuation or Quotation. Do not mention the language. All first letters of every word should be capitalized and write the title in User Language only.
${convo}
||>Title:`,
},
];

try {
title = (await this.sendPayload(instructionsPayload, { modelOptions })).replaceAll('"', '');
} catch (e) {
console.error(e);
console.log('There was another issue generating the title, see error above.');
}
}

console.log('CONVERSATION TITLE', title);
return title;
}
}

module.exports = OpenAIClient;
13 changes: 7 additions & 6 deletions api/app/clients/PluginsClient.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const OpenAIClient = require('./OpenAIClient');
const { CallbackManager } = require('langchain/callbacks');
const { HumanChatMessage, AIChatMessage } = require('langchain/schema');
const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents/');
const { addImages, createLLM, buildErrorInput, buildPromptPrefix } = require('./agents/methods/');
const { SelfReflectionTool } = require('./tools/');
const { initializeCustomAgent, initializeFunctionsAgent } = require('./agents');
const { addImages, buildErrorInput, buildPromptPrefix } = require('./output_parsers');
const { SelfReflectionTool } = require('./tools');
const { loadTools } = require('./tools/util');
const { createLLM } = require('./llm');

class PluginsClient extends OpenAIClient {
constructor(apiKey, options = {}) {
Expand All @@ -28,9 +29,9 @@ class PluginsClient extends OpenAIClient {
super.setOptions(options);
this.isGpt3 = this.modelOptions.model.startsWith('gpt-3');

if (this.options.reverseProxyUrl) {
this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0];
}
// if (this.options.reverseProxyUrl) {
// this.langchainProxy = this.options.reverseProxyUrl.match(/.*v1/)[0];
// }
}

getSaveOptions() {
Expand Down
5 changes: 5 additions & 0 deletions api/app/clients/chains/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const runTitleChain = require('./runTitleChain');

module.exports = {
runTitleChain,
};
43 changes: 43 additions & 0 deletions api/app/clients/chains/runTitleChain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const { z } = require('zod');
const { langPrompt, createTitlePrompt } = require('../prompts');
const { escapeBraces, getSnippet } = require('../output_parsers');
const { createStructuredOutputChainFromZod } = require('langchain/chains/openai_functions');

const langSchema = z.object({
language: z.string().describe('The language of the input text (full noun, no abbreviations).'),
});

const createLanguageChain = ({ llm }) =>
createStructuredOutputChainFromZod(langSchema, {
prompt: langPrompt,
llm,
// verbose: true,
});

const titleSchema = z.object({
title: z.string().describe('The title-cased title of the conversation in the given language.'),
});
const createTitleChain = ({ llm, convo }) => {
const titlePrompt = createTitlePrompt({ convo });
return createStructuredOutputChainFromZod(titleSchema, {
prompt: titlePrompt,
llm,
// verbose: true,
});
};

const runTitleChain = async ({ llm, text, convo }) => {
let snippet = text;
try {
snippet = getSnippet(text);
} catch (e) {
console.log('Error getting snippet of text for titleChain');
console.log(e);
}
const languageChain = createLanguageChain({ llm });
const titleChain = createTitleChain({ llm, convo: escapeBraces(convo) });
const { language } = await languageChain.run(snippet);
return (await titleChain.run(language)).title;
};

module.exports = runTitleChain;
File renamed without changes.
5 changes: 5 additions & 0 deletions api/app/clients/llm/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const createLLM = require('./createLLM');

module.exports = {
createLLM,
};
File renamed without changes.
38 changes: 38 additions & 0 deletions api/app/clients/output_parsers/handleInputs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Escaping curly braces is necessary for LangChain to correctly process the prompt
function escapeBraces(str) {
return str
.replace(/({{2,})|(}{2,})/g, (match) => `${match[0]}`)
.replace(/{|}/g, (match) => `${match}${match}`);
}

function getSnippet(text) {
let limit = 50;
let splitText = escapeBraces(text).split(' ');

if (splitText.length === 1 && splitText[0].length > limit) {
return splitText[0].substring(0, limit);
}

let result = '';
let spaceCount = 0;

for (let i = 0; i < splitText.length; i++) {
if (result.length + splitText[i].length <= limit) {
result += splitText[i] + ' ';
spaceCount++;
} else {
break;
}

if (spaceCount == 10) {
break;
}
}

return result.trim();
}

module.exports = {
escapeBraces,
getSnippet,
};
Loading

0 comments on commit 4ca43fb

Please sign in to comment.