🪶 feat: Add Support for Azure OpenAI Base URL (#1596)

* refactor(extractBaseURL): add handling for all possible Cloudflare AI Gateway endpoints

* chore: added endpointoption todo for updating type and optimizing handling app-wide

* feat(azureUtils):
- `genAzureChatCompletion`: allow optional client pass to update azure property
- `constructAzureURL`: optionally replace placeholders for instance and deployment names of an azure baseURL
- add tests for module

* refactor(extractBaseURL): return entire input when cloudflare `azure-openai` suffix detected
- also add more tests for both construct and extract URL

* refactor(genAzureChatCompletion): only allow omitting instance name if baseURL is not set

* refactor(initializeClient): determine `reverseProxyUrl` based on endpoint (azure or openai)

* refactor: utitlize `constructAzureURL` when `AZURE_OPENAI_BASEURL` is set

* docs: update docs on `AZURE_OPENAI_BASEURL`

* fix(ci): update expected error message for `azureUtils` tests
This commit is contained in:
Danny Avila 2024-01-19 14:57:03 -05:00 committed by GitHub
parent 5c94f5330a
commit e73608ba46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 532 additions and 47 deletions

View file

@ -2,8 +2,13 @@ const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { getResponseSender, ImageDetailCost, ImageDetail } = require('librechat-data-provider'); const { getResponseSender, ImageDetailCost, ImageDetail } = require('librechat-data-provider');
const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken');
const {
getModelMaxTokens,
genAzureChatCompletion,
extractBaseURL,
constructAzureURL,
} = require('~/utils');
const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images'); const { encodeAndFormat, validateVisionModel } = require('~/server/services/Files/images');
const { getModelMaxTokens, genAzureChatCompletion, extractBaseURL } = require('~/utils');
const { truncateText, formatMessage, CUT_OFF_PROMPT } = require('./prompts'); const { truncateText, formatMessage, CUT_OFF_PROMPT } = require('./prompts');
const { handleOpenAIErrors } = require('./tools/util'); const { handleOpenAIErrors } = require('./tools/util');
const spendTokens = require('~/models/spendTokens'); const spendTokens = require('~/models/spendTokens');
@ -32,6 +37,7 @@ class OpenAIClient extends BaseClient {
? options.contextStrategy.toLowerCase() ? options.contextStrategy.toLowerCase()
: 'discard'; : 'discard';
this.shouldSummarize = this.contextStrategy === 'summarize'; this.shouldSummarize = this.contextStrategy === 'summarize';
/** @type {AzureOptions} */
this.azure = options.azure || false; this.azure = options.azure || false;
this.setOptions(options); this.setOptions(options);
} }
@ -104,10 +110,10 @@ class OpenAIClient extends BaseClient {
} }
if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) { if (this.azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) {
this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model); this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this);
this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL; this.modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL;
} else if (this.azure) { } else if (this.azure) {
this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model); this.azureEndpoint = genAzureChatCompletion(this.azure, this.modelOptions.model, this);
} }
const { model } = this.modelOptions; const { model } = this.modelOptions;
@ -711,7 +717,7 @@ class OpenAIClient extends BaseClient {
if (this.azure) { if (this.azure) {
modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL ?? modelOptions.model; modelOptions.model = process.env.AZURE_OPENAI_DEFAULT_MODEL ?? modelOptions.model;
this.azureEndpoint = genAzureChatCompletion(this.azure, modelOptions.model); this.azureEndpoint = genAzureChatCompletion(this.azure, modelOptions.model, this);
} }
const instructionsPayload = [ const instructionsPayload = [
@ -949,7 +955,12 @@ ${convo}
// Azure does not accept `model` in the body, so we need to remove it. // Azure does not accept `model` in the body, so we need to remove it.
delete modelOptions.model; delete modelOptions.model;
opts.baseURL = this.azureEndpoint.split('/chat')[0]; opts.baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azure: this.azure,
})
: this.azureEndpoint.split(/\/(chat|completion)/)[0];
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion }; opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
opts.defaultHeaders = { ...opts.defaultHeaders, 'api-key': this.apiKey }; opts.defaultHeaders = { ...opts.defaultHeaders, 'api-key': this.apiKey };
} }

View file

@ -1,6 +1,6 @@
const { ChatOpenAI } = require('langchain/chat_models/openai'); const { ChatOpenAI } = require('langchain/chat_models/openai');
const { sanitizeModelName } = require('../../../utils'); const { sanitizeModelName, constructAzureURL } = require('~/utils');
const { isEnabled } = require('../../../server/utils'); const { isEnabled } = require('~/server/utils');
/** /**
* Creates a new instance of a language model (LLM) for chat interactions. * Creates a new instance of a language model (LLM) for chat interactions.
@ -36,6 +36,7 @@ function createLLM({
apiKey: openAIApiKey, apiKey: openAIApiKey,
}; };
/** @type {AzureOptions} */
let azureOptions = {}; let azureOptions = {};
if (azure) { if (azure) {
const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME); const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME);
@ -53,8 +54,12 @@ function createLLM({
modelOptions.modelName = process.env.AZURE_OPENAI_DEFAULT_MODEL; modelOptions.modelName = process.env.AZURE_OPENAI_DEFAULT_MODEL;
} }
// console.debug('createLLM: configOptions'); if (azure && configOptions.basePath) {
// console.debug(configOptions); configOptions.basePath = constructAzureURL({
baseURL: configOptions.basePath,
azure: azureOptions,
});
}
return new ChatOpenAI( return new ChatOpenAI(
{ {

View file

@ -1,7 +1,8 @@
const { PluginsClient } = require('~/app'); const { EModelEndpoint } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { getAzureCredentials } = require('~/utils');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getAzureCredentials } = require('~/utils');
const { isEnabled } = require('~/server/utils');
const { PluginsClient } = require('~/app');
const initializeClient = async ({ req, res, endpointOption }) => { const initializeClient = async ({ req, res, endpointOption }) => {
const { const {
@ -10,26 +11,40 @@ const initializeClient = async ({ req, res, endpointOption }) => {
AZURE_API_KEY, AZURE_API_KEY,
PLUGINS_USE_AZURE, PLUGINS_USE_AZURE,
OPENAI_REVERSE_PROXY, OPENAI_REVERSE_PROXY,
AZURE_OPENAI_BASEURL,
OPENAI_SUMMARIZE, OPENAI_SUMMARIZE,
DEBUG_PLUGINS, DEBUG_PLUGINS,
} = process.env; } = process.env;
const { key: expiresAt } = req.body; const { key: expiresAt } = req.body;
const contextStrategy = isEnabled(OPENAI_SUMMARIZE) ? 'summarize' : null; const contextStrategy = isEnabled(OPENAI_SUMMARIZE) ? 'summarize' : null;
const useAzure = isEnabled(PLUGINS_USE_AZURE);
const endpoint = useAzure ? EModelEndpoint.azureOpenAI : EModelEndpoint.openAI;
const baseURLOptions = {
[EModelEndpoint.openAI]: OPENAI_REVERSE_PROXY,
[EModelEndpoint.azureOpenAI]: AZURE_OPENAI_BASEURL,
};
const reverseProxyUrl = baseURLOptions[endpoint] ?? null;
const clientOptions = { const clientOptions = {
contextStrategy, contextStrategy,
debug: isEnabled(DEBUG_PLUGINS), debug: isEnabled(DEBUG_PLUGINS),
reverseProxyUrl: OPENAI_REVERSE_PROXY ?? null, reverseProxyUrl,
proxy: PROXY ?? null, proxy: PROXY ?? null,
req, req,
res, res,
...endpointOption, ...endpointOption,
}; };
const useAzure = isEnabled(PLUGINS_USE_AZURE); const credentials = {
[EModelEndpoint.openAI]: OPENAI_API_KEY,
[EModelEndpoint.azureOpenAI]: AZURE_API_KEY,
};
const isUserProvided = useAzure const isUserProvided = credentials[endpoint] === 'user_provided';
? AZURE_API_KEY === 'user_provided'
: OPENAI_API_KEY === 'user_provided';
let userKey = null; let userKey = null;
if (expiresAt && isUserProvided) { if (expiresAt && isUserProvided) {
@ -39,11 +54,11 @@ const initializeClient = async ({ req, res, endpointOption }) => {
); );
userKey = await getUserKey({ userKey = await getUserKey({
userId: req.user.id, userId: req.user.id,
name: useAzure ? 'azureOpenAI' : 'openAI', name: endpoint,
}); });
} }
let apiKey = isUserProvided ? userKey : OPENAI_API_KEY; let apiKey = isUserProvided ? userKey : credentials[endpoint];
if (useAzure || (apiKey && apiKey.includes('azure') && !clientOptions.azure)) { if (useAzure || (apiKey && apiKey.includes('azure') && !clientOptions.azure)) {
clientOptions.azure = isUserProvided ? JSON.parse(userKey) : getAzureCredentials(); clientOptions.azure = isUserProvided ? JSON.parse(userKey) : getAzureCredentials();

View file

@ -1,7 +1,8 @@
const { OpenAIClient } = require('~/app'); const { EModelEndpoint } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const { getAzureCredentials } = require('~/utils');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getAzureCredentials } = require('~/utils');
const { isEnabled } = require('~/server/utils');
const { OpenAIClient } = require('~/app');
const initializeClient = async ({ req, res, endpointOption }) => { const initializeClient = async ({ req, res, endpointOption }) => {
const { const {
@ -9,15 +10,24 @@ const initializeClient = async ({ req, res, endpointOption }) => {
OPENAI_API_KEY, OPENAI_API_KEY,
AZURE_API_KEY, AZURE_API_KEY,
OPENAI_REVERSE_PROXY, OPENAI_REVERSE_PROXY,
AZURE_OPENAI_BASEURL,
OPENAI_SUMMARIZE, OPENAI_SUMMARIZE,
DEBUG_OPENAI, DEBUG_OPENAI,
} = process.env; } = process.env;
const { key: expiresAt, endpoint } = req.body; const { key: expiresAt, endpoint } = req.body;
const contextStrategy = isEnabled(OPENAI_SUMMARIZE) ? 'summarize' : null; const contextStrategy = isEnabled(OPENAI_SUMMARIZE) ? 'summarize' : null;
const baseURLOptions = {
[EModelEndpoint.openAI]: OPENAI_REVERSE_PROXY,
[EModelEndpoint.azureOpenAI]: AZURE_OPENAI_BASEURL,
};
const reverseProxyUrl = baseURLOptions[endpoint] ?? null;
const clientOptions = { const clientOptions = {
debug: isEnabled(DEBUG_OPENAI), debug: isEnabled(DEBUG_OPENAI),
contextStrategy, contextStrategy,
reverseProxyUrl: OPENAI_REVERSE_PROXY ?? null, reverseProxyUrl,
proxy: PROXY ?? null, proxy: PROXY ?? null,
req, req,
res, res,
@ -25,8 +35,8 @@ const initializeClient = async ({ req, res, endpointOption }) => {
}; };
const credentials = { const credentials = {
openAI: OPENAI_API_KEY, [EModelEndpoint.openAI]: OPENAI_API_KEY,
azureOpenAI: AZURE_API_KEY, [EModelEndpoint.azureOpenAI]: AZURE_API_KEY,
}; };
const isUserProvided = credentials[endpoint] === 'user_provided'; const isUserProvided = credentials[endpoint] === 'user_provided';
@ -42,7 +52,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
let apiKey = isUserProvided ? userKey : credentials[endpoint]; let apiKey = isUserProvided ? userKey : credentials[endpoint];
if (endpoint === 'azureOpenAI') { if (endpoint === EModelEndpoint.azureOpenAI) {
clientOptions.azure = isUserProvided ? JSON.parse(userKey) : getAzureCredentials(); clientOptions.azure = isUserProvided ? JSON.parse(userKey) : getAzureCredentials();
apiKey = clientOptions.azure.azureOpenAIApiKey; apiKey = clientOptions.azure.azureOpenAIApiKey;
} }

View file

@ -1,11 +1,3 @@
/**
* @typedef {Object} AzureCredentials
* @property {string} azureOpenAIApiKey - The Azure OpenAI API key.
* @property {string} azureOpenAIApiInstanceName - The Azure OpenAI API instance name.
* @property {string} azureOpenAIApiDeploymentName - The Azure OpenAI API deployment name.
* @property {string} azureOpenAIApiVersion - The Azure OpenAI API version.
*/
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
/** /**
@ -37,22 +29,29 @@ const genAzureEndpoint = ({ azureOpenAIApiInstanceName, azureOpenAIApiDeployment
* @param {string} [AzureConfig.azureOpenAIApiDeploymentName] - The Azure OpenAI API deployment name (optional). * @param {string} [AzureConfig.azureOpenAIApiDeploymentName] - The Azure OpenAI API deployment name (optional).
* @param {string} AzureConfig.azureOpenAIApiVersion - The Azure OpenAI API version. * @param {string} AzureConfig.azureOpenAIApiVersion - The Azure OpenAI API version.
* @param {string} [modelName] - The model name to be included in the deployment name (optional). * @param {string} [modelName] - The model name to be included in the deployment name (optional).
* @param {Object} [client] - The API Client class for optionally setting properties (optional).
* @returns {string} The complete chat completion endpoint URL for the Azure OpenAI API. * @returns {string} The complete chat completion endpoint URL for the Azure OpenAI API.
* @throws {Error} If neither azureOpenAIApiDeploymentName nor modelName is provided. * @throws {Error} If neither azureOpenAIApiDeploymentName nor modelName is provided.
*/ */
const genAzureChatCompletion = ( const genAzureChatCompletion = (
{ azureOpenAIApiInstanceName, azureOpenAIApiDeploymentName, azureOpenAIApiVersion }, { azureOpenAIApiInstanceName, azureOpenAIApiDeploymentName, azureOpenAIApiVersion },
modelName, modelName,
client,
) => { ) => {
// Determine the deployment segment of the URL based on provided modelName or azureOpenAIApiDeploymentName // Determine the deployment segment of the URL based on provided modelName or azureOpenAIApiDeploymentName
let deploymentSegment; let deploymentSegment;
if (isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME) && modelName) { if (isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME) && modelName) {
const sanitizedModelName = sanitizeModelName(modelName); const sanitizedModelName = sanitizeModelName(modelName);
deploymentSegment = `${sanitizedModelName}`; deploymentSegment = `${sanitizedModelName}`;
client &&
typeof client === 'object' &&
(client.azure.azureOpenAIApiDeploymentName = sanitizedModelName);
} else if (azureOpenAIApiDeploymentName) { } else if (azureOpenAIApiDeploymentName) {
deploymentSegment = azureOpenAIApiDeploymentName; deploymentSegment = azureOpenAIApiDeploymentName;
} else { } else if (!process.env.AZURE_OPENAI_BASEURL) {
throw new Error('Either a model name or a deployment name must be provided.'); throw new Error(
'Either a model name with the `AZURE_USE_MODEL_AS_DEPLOYMENT_NAME` setting or a deployment name must be provided if `AZURE_OPENAI_BASEURL` is omitted.',
);
} }
return `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${deploymentSegment}/chat/completions?api-version=${azureOpenAIApiVersion}`; return `https://${azureOpenAIApiInstanceName}.openai.azure.com/openai/deployments/${deploymentSegment}/chat/completions?api-version=${azureOpenAIApiVersion}`;
@ -60,7 +59,7 @@ const genAzureChatCompletion = (
/** /**
* Retrieves the Azure OpenAI API credentials from environment variables. * Retrieves the Azure OpenAI API credentials from environment variables.
* @returns {AzureCredentials} An object containing the Azure OpenAI API credentials. * @returns {AzureOptions} An object containing the Azure OpenAI API credentials.
*/ */
const getAzureCredentials = () => { const getAzureCredentials = () => {
return { return {
@ -71,9 +70,33 @@ const getAzureCredentials = () => {
}; };
}; };
/**
* Constructs a URL by replacing placeholders in the baseURL with values from the azure object.
* It specifically looks for '${INSTANCE_NAME}' and '${DEPLOYMENT_NAME}' within the baseURL and replaces
* them with 'azureOpenAIApiInstanceName' and 'azureOpenAIApiDeploymentName' from the azure object.
* If the respective azure property is not provided, the placeholder is replaced with an empty string.
*
* @param {Object} params - The parameters object.
* @param {string} params.baseURL - The baseURL to inspect for replacement placeholders.
* @param {AzureOptions} params.azure - The baseURL to inspect for replacement placeholders.
* @returns {string} The complete baseURL with credentials injected for the Azure OpenAI API.
*/
function constructAzureURL({ baseURL, azure }) {
let finalURL = baseURL;
// Replace INSTANCE_NAME and DEPLOYMENT_NAME placeholders with actual values if available
if (azure) {
finalURL = finalURL.replace('${INSTANCE_NAME}', azure.azureOpenAIApiInstanceName ?? '');
finalURL = finalURL.replace('${DEPLOYMENT_NAME}', azure.azureOpenAIApiDeploymentName ?? '');
}
return finalURL;
}
module.exports = { module.exports = {
sanitizeModelName, sanitizeModelName,
genAzureEndpoint, genAzureEndpoint,
genAzureChatCompletion, genAzureChatCompletion,
getAzureCredentials, getAzureCredentials,
constructAzureURL,
}; };

View file

@ -0,0 +1,268 @@
const {
sanitizeModelName,
genAzureEndpoint,
genAzureChatCompletion,
getAzureCredentials,
constructAzureURL,
} = require('./azureUtils');
describe('sanitizeModelName', () => {
test('removes periods from the model name', () => {
const sanitized = sanitizeModelName('model.name');
expect(sanitized).toBe('modelname');
});
test('leaves model name unchanged if no periods are present', () => {
const sanitized = sanitizeModelName('modelname');
expect(sanitized).toBe('modelname');
});
});
describe('genAzureEndpoint', () => {
test('generates correct endpoint URL', () => {
const url = genAzureEndpoint({
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
});
expect(url).toBe('https://instanceName.openai.azure.com/openai/deployments/deploymentName');
});
});
describe('genAzureChatCompletion', () => {
// Test with both deployment name and model name provided
test('prefers model name over deployment name when both are provided and feature enabled', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
},
'modelName',
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/modelName/chat/completions?api-version=v1',
);
});
// Test with only deployment name provided
test('uses deployment name when model name is not provided', () => {
const url = genAzureChatCompletion({
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
});
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/deploymentName/chat/completions?api-version=v1',
);
});
// Test with only model name provided
test('uses model name when deployment name is not provided and feature enabled', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiVersion: 'v1',
},
'modelName',
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/modelName/chat/completions?api-version=v1',
);
});
// Test with neither deployment name nor model name provided
test('throws error if neither deployment name nor model name is provided', () => {
expect(() => {
genAzureChatCompletion({
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiVersion: 'v1',
});
}).toThrow(
'Either a model name with the `AZURE_USE_MODEL_AS_DEPLOYMENT_NAME` setting or a deployment name must be provided if `AZURE_OPENAI_BASEURL` is omitted.',
);
});
// Test with feature disabled but model name provided
test('ignores model name and uses deployment name when feature is disabled', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'false';
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
},
'modelName',
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/deploymentName/chat/completions?api-version=v1',
);
});
// Test with sanitized model name
test('sanitizes model name when used in URL', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiVersion: 'v1',
},
'model.name',
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/modelname/chat/completions?api-version=v1',
);
});
// Test with client parameter and model name
test('updates client with sanitized model name when provided and feature enabled', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'true';
const clientMock = { azure: {} };
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiVersion: 'v1',
},
'model.name',
clientMock,
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/modelname/chat/completions?api-version=v1',
);
expect(clientMock.azure.azureOpenAIApiDeploymentName).toBe('modelname');
});
// Test with client parameter but without model name
test('does not update client when model name is not provided', () => {
const clientMock = { azure: {} };
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
},
undefined,
clientMock,
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/deploymentName/chat/completions?api-version=v1',
);
expect(clientMock.azure.azureOpenAIApiDeploymentName).toBeUndefined();
});
// Test with client parameter and deployment name when feature is disabled
test('does not update client when feature is disabled', () => {
process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME = 'false';
const clientMock = { azure: {} };
const url = genAzureChatCompletion(
{
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
},
'modelName',
clientMock,
);
expect(url).toBe(
'https://instanceName.openai.azure.com/openai/deployments/deploymentName/chat/completions?api-version=v1',
);
expect(clientMock.azure.azureOpenAIApiDeploymentName).toBeUndefined();
});
// Reset environment variable after tests
afterEach(() => {
delete process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME;
});
});
describe('getAzureCredentials', () => {
beforeEach(() => {
process.env.AZURE_API_KEY = 'testApiKey';
process.env.AZURE_OPENAI_API_INSTANCE_NAME = 'instanceName';
process.env.AZURE_OPENAI_API_DEPLOYMENT_NAME = 'deploymentName';
process.env.AZURE_OPENAI_API_VERSION = 'v1';
});
test('retrieves Azure OpenAI API credentials from environment variables', () => {
const credentials = getAzureCredentials();
expect(credentials).toEqual({
azureOpenAIApiKey: 'testApiKey',
azureOpenAIApiInstanceName: 'instanceName',
azureOpenAIApiDeploymentName: 'deploymentName',
azureOpenAIApiVersion: 'v1',
});
});
});
describe('constructAzureURL', () => {
test('replaces both placeholders when both properties are provided', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}',
azure: {
azureOpenAIApiInstanceName: 'instance1',
azureOpenAIApiDeploymentName: 'deployment1',
},
});
expect(url).toBe('https://example.com/instance1/deployment1');
});
test('replaces only INSTANCE_NAME when only azureOpenAIApiInstanceName is provided', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}',
azure: {
azureOpenAIApiInstanceName: 'instance2',
},
});
expect(url).toBe('https://example.com/instance2/');
});
test('replaces only DEPLOYMENT_NAME when only azureOpenAIApiDeploymentName is provided', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}',
azure: {
azureOpenAIApiDeploymentName: 'deployment2',
},
});
expect(url).toBe('https://example.com//deployment2');
});
test('does not replace any placeholders when azure object is empty', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}',
azure: {},
});
expect(url).toBe('https://example.com//');
});
test('returns baseURL as is when azure object is not provided', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}',
});
expect(url).toBe('https://example.com/${INSTANCE_NAME}/${DEPLOYMENT_NAME}');
});
test('returns baseURL as is when no placeholders are set', () => {
const url = constructAzureURL({
baseURL: 'https://example.com/my_custom_instance/my_deployment',
azure: {
azureOpenAIApiInstanceName: 'instance1',
azureOpenAIApiDeploymentName: 'deployment1',
},
});
expect(url).toBe('https://example.com/my_custom_instance/my_deployment');
});
test('returns regular Azure OpenAI baseURL with placeholders set', () => {
const baseURL =
'https://${INSTANCE_NAME}.openai.azure.com/openai/deployments/${DEPLOYMENT_NAME}';
const url = constructAzureURL({
baseURL,
azure: {
azureOpenAIApiInstanceName: 'instance1',
azureOpenAIApiDeploymentName: 'deployment1',
},
});
expect(url).toBe('https://instance1.openai.azure.com/openai/deployments/deployment1');
});
});

View file

@ -1,13 +1,15 @@
/** /**
* Extracts a valid OpenAI baseURL from a given string, matching "url/v1," also an added suffix, * Extracts a valid OpenAI baseURL from a given string, matching "url/v1," followed by an optional suffix.
* ending with "/openai" (to allow the Cloudflare, LiteLLM pattern). * The suffix can be one of several predefined values (e.g., 'openai', 'azure-openai', etc.),
* Returns the original URL if no match is found. * accommodating different proxy patterns like Cloudflare, LiteLLM, etc.
* Returns the original URL if no valid pattern is found.
* *
* Examples: * Examples:
* - `https://open.ai/v1/chat` -> `https://open.ai/v1` * - `https://open.ai/v1/chat` -> `https://open.ai/v1`
* - `https://open.ai/v1/chat/completions` -> `https://open.ai/v1` * - `https://open.ai/v1/chat/completions` -> `https://open.ai/v1`
* - `https://open.ai/v1/ACCOUNT/GATEWAY/openai/completions` -> `https://open.ai/v1/ACCOUNT/GATEWAY/openai` * - `https://gateway.ai.cloudflare.com/v1/account/gateway/azure-openai/completions` -> `https://gateway.ai.cloudflare.com/v1/account/gateway/azure-openai`
* - `https://open.ai/v1/hi/openai` -> `https://open.ai/v1/hi/openai` * - `https://open.ai/v1/hi/openai` -> `https://open.ai/v1/hi/openai`
* - `https://api.example.com/v1/replicate` -> `https://api.example.com/v1/replicate`
* *
* @param {string} url - The URL to be processed. * @param {string} url - The URL to be processed.
* @returns {string} The matched pattern or input if no match is found. * @returns {string} The matched pattern or input if no match is found.
@ -23,8 +25,27 @@ function extractBaseURL(url) {
// Extract the part of the URL up to and including '/v1'. // Extract the part of the URL up to and including '/v1'.
let baseUrl = url.substring(0, v1Index + 3); let baseUrl = url.substring(0, v1Index + 3);
const openai = 'openai';
// Find which suffix is present.
const suffixes = [
'azure-openai',
openai,
'replicate',
'huggingface',
'workers-ai',
'aws-bedrock',
];
const suffixUsed = suffixes.find((suffix) => url.includes(`/${suffix}`));
if (suffixUsed === 'azure-openai') {
return url.split(/\/(chat|completion)/)[0];
}
// Check if the URL has '/openai' immediately after '/v1'. // Check if the URL has '/openai' immediately after '/v1'.
const openaiIndex = url.indexOf('/openai', v1Index + 3); const openaiIndex = url.indexOf(`/${openai}`, v1Index + 3);
// Find which suffix is present in the URL, if any.
const suffixIndex =
suffixUsed === openai ? openaiIndex : url.indexOf(`/${suffixUsed}`, v1Index + 3);
// If '/openai' is found right after '/v1', include it in the base URL. // If '/openai' is found right after '/v1', include it in the base URL.
if (openaiIndex === v1Index + 3) { if (openaiIndex === v1Index + 3) {
@ -37,9 +58,9 @@ function extractBaseURL(url) {
// If there is a next slash, the base URL goes up to but not including the slash. // If there is a next slash, the base URL goes up to but not including the slash.
baseUrl = url.substring(0, nextSlashIndex); baseUrl = url.substring(0, nextSlashIndex);
} }
} else if (openaiIndex > 0) { } else if (suffixIndex > 0) {
// If '/openai' is present but not immediately after '/v1', we need to include the reverse proxy pattern. // If a suffix is present but not immediately after '/v1', we need to include the reverse proxy pattern.
baseUrl = url.substring(0, openaiIndex + 7); baseUrl = url.substring(0, suffixIndex + suffixUsed.length + 1);
} }
return baseUrl; return baseUrl;

View file

@ -53,4 +53,59 @@ describe('extractBaseURL', () => {
const url = 'https://open.ai/v1/hi/openai'; const url = 'https://open.ai/v1/hi/openai';
expect(extractBaseURL(url)).toBe('https://open.ai/v1/hi/openai'); expect(extractBaseURL(url)).toBe('https://open.ai/v1/hi/openai');
}); });
test('should handle Azure OpenAI Cloudflare endpoint correctly', () => {
const url = 'https://gateway.ai.cloudflare.com/v1/account/gateway/azure-openai/completions';
expect(extractBaseURL(url)).toBe(
'https://gateway.ai.cloudflare.com/v1/account/gateway/azure-openai',
);
});
test('should include various suffixes in the extracted URL when present', () => {
const urls = [
'https://api.example.com/v1/azure-openai/something',
'https://api.example.com/v1/replicate/anotherthing',
'https://api.example.com/v1/huggingface/yetanotherthing',
'https://api.example.com/v1/workers-ai/differentthing',
'https://api.example.com/v1/aws-bedrock/somethingelse',
];
const expected = [
/* Note: exception for azure-openai to allow credential injection */
'https://api.example.com/v1/azure-openai/something',
'https://api.example.com/v1/replicate',
'https://api.example.com/v1/huggingface',
'https://api.example.com/v1/workers-ai',
'https://api.example.com/v1/aws-bedrock',
];
urls.forEach((url, index) => {
expect(extractBaseURL(url)).toBe(expected[index]);
});
});
test('should handle URLs with suffixes not immediately after /v1', () => {
const url = 'https://api.example.com/v1/some/path/azure-openai';
expect(extractBaseURL(url)).toBe('https://api.example.com/v1/some/path/azure-openai');
});
test('should handle URLs with complex paths after the suffix', () => {
const url = 'https://api.example.com/v1/replicate/deep/path/segment';
expect(extractBaseURL(url)).toBe('https://api.example.com/v1/replicate');
});
test('should leave a regular Azure OpenAI baseURL as is', () => {
const url = 'https://instance-name.openai.azure.com/openai/deployments/deployment-name';
expect(extractBaseURL(url)).toBe(url);
});
test('should leave a regular Azure OpenAI baseURL with placeholders as is', () => {
const url = 'https://${INSTANCE_NAME}.openai.azure.com/openai/deployments/${DEPLOYMENT_NAME}';
expect(extractBaseURL(url)).toBe(url);
});
test('should leave an alternate Azure OpenAI baseURL with placeholders as is', () => {
const url = 'https://${INSTANCE_NAME}.com/resources/deployments/${DEPLOYMENT_NAME}';
expect(extractBaseURL(url)).toBe(url);
});
}); });

View file

@ -278,6 +278,45 @@ AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # do include periods in the model name
``` ```
### Using a Specified Base URL with Azure
The base URL for Azure OpenAI API requests can be dynamically configured. This is useful for proxying services such as [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/providers/azureopenai/), or if you wish to explicitly override the baseURL handling of the app.
LibreChat will use the `AZURE_OPENAI_BASEURL` environment variable, which can include placeholders for the Azure OpenAI API instance and deployment names.
In the application's environment configuration, the base URL is set like this:
```bash
# .env file
AZURE_OPENAI_BASEURL=https://example.azure-api.net/${INSTANCE_NAME}/${DEPLOYMENT_NAME}
# OR
AZURE_OPENAI_BASEURL=https://${INSTANCE_NAME}.openai.azure.com/openai/deployments/${DEPLOYMENT_NAME}
# Cloudflare example
AZURE_OPENAI_BASEURL=https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/azure-openai/${INSTANCE_NAME}/${DEPLOYMENT_NAME}
```
The application replaces `${INSTANCE_NAME}` and `${DEPLOYMENT_NAME}` in the `AZURE_OPENAI_BASEURL`, processed according to the other settings discussed in the guide.
**You can also omit the placeholders completely and simply construct the baseURL with your credentials:**
```bash
# .env file
AZURE_OPENAI_BASEURL=https://instance-1.openai.azure.com/openai/deployments/deployment-1
# Cloudflare example
AZURE_OPENAI_BASEURL=https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/azure-openai/instance-1/deployment-1
```
Setting these values will override all of the application's internal handling of the instance and deployment names and use your specified base URL.
**Notes:**
- You should still provide the `AZURE_OPENAI_API_VERSION` and `AZURE_API_KEY` via the .env file as they are programmatically added to the requests.
- When specifying instance and deployment names in the `AZURE_OPENAI_BASEURL`, their respective environment variables can be omitted (`AZURE_OPENAI_API_INSTANCE_NAME` and `AZURE_OPENAI_API_DEPLOYMENT_NAME`) except for use with Plugins.
- Specifying instance and deployment names in the `AZURE_OPENAI_BASEURL` instead of placeholders creates conflicts with "plugins," "vision," "default-model," and "model-as-deployment-name" support.
- Due to the conflicts that arise with other features, it is recommended to use placeholder for instance and deployment names in the `AZURE_OPENAI_BASEURL`
### Enabling Auto-Generated Titles with Azure ### Enabling Auto-Generated Titles with Azure
The default titling model is set to `gpt-3.5-turbo`. The default titling model is set to `gpt-3.5-turbo`.
@ -294,7 +333,10 @@ This will work seamlessly as it does with the [OpenAI endpoint](#openai) (no nee
Alternatively, you can set the [required variables](#required-variables) to explicitly use your vision deployment, but this may limit you to exclusively using your vision deployment for all Azure chat settings. Alternatively, you can set the [required variables](#required-variables) to explicitly use your vision deployment, but this may limit you to exclusively using your vision deployment for all Azure chat settings.
As of December 18th, 2023, Vision models seem to have degraded performance with Azure OpenAI when compared to [OpenAI](#openai)
**Notes:**
- If using `AZURE_OPENAI_BASEURL`, you should not specify instance and deployment names instead of placeholders as the vision request will fail.
- As of December 18th, 2023, Vision models seem to have degraded performance with Azure OpenAI when compared to [OpenAI](#openai)
![image](https://github.com/danny-avila/LibreChat/assets/110412045/7306185f-c32c-4483-9167-af514cc1c2dd) ![image](https://github.com/danny-avila/LibreChat/assets/110412045/7306185f-c32c-4483-9167-af514cc1c2dd)
@ -361,6 +403,9 @@ To use Azure with the Plugins endpoint, make sure the following environment vari
* `PLUGINS_USE_AZURE`: If set to "true" or any truthy value, this will enable the program to use Azure with the Plugins endpoint. * `PLUGINS_USE_AZURE`: If set to "true" or any truthy value, this will enable the program to use Azure with the Plugins endpoint.
* `AZURE_API_KEY`: Your Azure API key must be set with an environment variable. * `AZURE_API_KEY`: Your Azure API key must be set with an environment variable.
**Important:**
- If using `AZURE_OPENAI_BASEURL`, you should not specify instance and deployment names instead of placeholders as the plugin request will fail.
--- ---
## [OpenRouter](https://openrouter.ai/) ## [OpenRouter](https://openrouter.ai/)

View file

@ -4,6 +4,21 @@ description: Comprehensive guide for configuring the `librechat.yaml` file AKA t
weight: -10 weight: -10
--- ---
<!-- # Table of Contents
- [Intro](#librechat-configuration-guide)
- [Setup](#setup)
- [Docker Setup](#docker-setup)
- [Config Structure](#config-structure)
- [1. Version](#1-version)
- [2. Cache Settings](#2-cache-settings)
- [3. Endpoints](#3-endpoints)
- [Endpoint Object Structure](#endpoint-object-structure)
- [Additional Notes](#additional-notes)
- [Default Parameters](#default-parameters)
- [Breakdown of Default Params](#breakdown-of-default-params)
- [Example Config](#example-config) -->
# LibreChat Configuration Guide # LibreChat Configuration Guide
Welcome to the guide for configuring the **librechat.yaml** file in LibreChat. Welcome to the guide for configuring the **librechat.yaml** file in LibreChat.

View file

@ -177,6 +177,20 @@ AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME=
- Identify the available models, separated by commas *without spaces*. The first will be default. Leave it blank or as is to use internal settings. - Identify the available models, separated by commas *without spaces*. The first will be default. Leave it blank or as is to use internal settings.
- **The base URL for Azure OpenAI API requests can be dynamically configured.**
```bash
# .env file
AZURE_OPENAI_BASEURL=https://${INSTANCE_NAME}.openai.azure.com/openai/deployments/${DEPLOYMENT_NAME}
# Cloudflare example
AZURE_OPENAI_BASEURL=https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/azure-openai/${INSTANCE_NAME}/${DEPLOYMENT_NAME}
```
- Sets the base URL for Azure OpenAI API requests.
- Can include `${INSTANCE_NAME}` and `${DEPLOYMENT_NAME}` placeholders or specific credentials.
- Example: "https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/azure-openai/${INSTANCE_NAME}/${DEPLOYMENT_NAME}"
- [More info about `AZURE_OPENAI_BASEURL` here](./ai_setup.md#using-a-specified-base-url-with-azure)
> Note: as deployment names can't have periods, they will be removed when the endpoint is generated. > Note: as deployment names can't have periods, they will be removed when the endpoint is generated.
```bash ```bash

View file

@ -1,5 +1,5 @@
import OpenAI from 'openai'; import OpenAI from 'openai';
import type { TResPlugin, TMessage, TConversation, EModelEndpoint } from './schemas'; import type { TResPlugin, TMessage, TConversation, EModelEndpoint, ImageDetail } from './schemas';
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam; export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
export type TOpenAIFunction = OpenAI.Chat.ChatCompletionCreateParams.Function; export type TOpenAIFunction = OpenAI.Chat.ChatCompletionCreateParams.Function;
@ -11,10 +11,13 @@ export type TMessages = TMessage[];
export type TMessagesAtom = TMessages | null; export type TMessagesAtom = TMessages | null;
/* TODO: Cleanup EndpointOption types */
export type TEndpointOption = { export type TEndpointOption = {
endpoint: EModelEndpoint; endpoint: EModelEndpoint;
endpointType?: EModelEndpoint; endpointType?: EModelEndpoint;
modelDisplayLabel?: string; modelDisplayLabel?: string;
resendImages?: boolean;
imageDetail?: ImageDetail;
model?: string | null; model?: string | null;
promptPrefix?: string; promptPrefix?: string;
temperature?: number; temperature?: number;