mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 01:40:15 +01:00
🧵 refactor: Migrate Endpoint Initialization to TypeScript (#10794)
* refactor: move endpoint initialization methods to typescript * refactor: move agent init to packages/api - Introduced `initialize.ts` for agent initialization, including file processing and tool loading. - Updated `resources.ts` to allow optional appConfig parameter. - Enhanced endpoint configuration handling in various initialization files to support model parameters. - Added new artifacts and prompts for React component generation. - Refactored existing code to improve type safety and maintainability. * refactor: streamline endpoint initialization and enhance type safety - Updated initialization functions across various endpoints to use a consistent request structure, replacing `unknown` types with `ServerResponse`. - Simplified request handling by directly extracting keys from the request body. - Improved type safety by ensuring user IDs are safely accessed with optional chaining. - Removed unnecessary parameters and streamlined model options handling for better clarity and maintainability. * refactor: moved ModelService and extractBaseURL to packages/api - Added comprehensive tests for the models fetching functionality, covering scenarios for OpenAI, Anthropic, Google, and Ollama models. - Updated existing endpoint index to include the new models module. - Enhanced utility functions for URL extraction and model data processing. - Improved type safety and error handling across the models fetching logic. * refactor: consolidate utility functions and remove unused files - Merged `deriveBaseURL` and `extractBaseURL` into the `@librechat/api` module for better organization. - Removed redundant utility files and their associated tests to streamline the codebase. - Updated imports across various client files to utilize the new consolidated functions. - Enhanced overall maintainability by reducing the number of utility modules. * refactor: replace ModelService references with direct imports from @librechat/api and remove ModelService file * refactor: move encrypt/decrypt methods and key db methods to data-schemas, use `getProviderConfig` from `@librechat/api` * chore: remove unused 'res' from options in AgentClient * refactor: file model imports and methods - Updated imports in various controllers and services to use the unified file model from '~/models' instead of '~/models/File'. - Consolidated file-related methods into a new file methods module in the data-schemas package. - Added comprehensive tests for file methods including creation, retrieval, updating, and deletion. - Enhanced the initializeAgent function to accept dependency injection for file-related methods. - Improved error handling and logging in file methods. * refactor: streamline database method references in agent initialization * refactor: enhance file method tests and update type references to IMongoFile * refactor: consolidate database method imports in agent client and initialization * chore: remove redundant import of initializeAgent from @librechat/api * refactor: move checkUserKeyExpiry utility to @librechat/api and update references across endpoints * refactor: move updateUserPlugins logic to user.ts and simplify UserController * refactor: update imports for user key management and remove UserService * refactor: remove unused Anthropics and Bedrock endpoint files and clean up imports * refactor: consolidate and update encryption imports across various files to use @librechat/data-schemas * chore: update file model mock to use unified import from '~/models' * chore: import order * refactor: remove migrated to TS agent.js file and its associated logic from the endpoints * chore: add reusable function to extract imports from source code in unused-packages workflow * chore: enhance unused-packages workflow to include @librechat/api dependencies and improve dependency extraction * chore: improve dependency extraction in unused-packages workflow with enhanced error handling and debugging output * chore: add detailed debugging output to unused-packages workflow for better visibility into unused dependencies and exclusion lists * chore: refine subpath handling in unused-packages workflow to correctly process scoped and non-scoped package imports * chore: clean up unused debug output in unused-packages workflow and reorganize type imports in initialize.ts
This commit is contained in:
parent
f2ba1696bc
commit
b478560c81
103 changed files with 4135 additions and 2647 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, decrypt } from '@librechat/data-schemas';
|
||||
import type { IPluginAuth, PluginAuthMethods } from '@librechat/data-schemas';
|
||||
import { decrypt } from '../crypto/encryption';
|
||||
|
||||
export interface GetPluginAuthMapParams {
|
||||
userId: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export * from './chain';
|
||||
export * from './initialize';
|
||||
export * from './legacy';
|
||||
export * from './memory';
|
||||
export * from './migration';
|
||||
export * from './legacy';
|
||||
export * from './resources';
|
||||
export * from './run';
|
||||
export * from './validation';
|
||||
|
|
|
|||
315
packages/api/src/agents/initialize.ts
Normal file
315
packages/api/src/agents/initialize.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import {
|
||||
ErrorTypes,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
paramEndpoints,
|
||||
isAgentsEndpoint,
|
||||
replaceSpecialVars,
|
||||
providerEndpointMap,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
AgentToolResources,
|
||||
TEndpointOption,
|
||||
TFile,
|
||||
Agent,
|
||||
TUser,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Response as ServerResponse } from 'express';
|
||||
import type { IMongoFile } from '@librechat/data-schemas';
|
||||
import type { GenericTool } from '@librechat/agents';
|
||||
import type { InitializeResultBase, ServerRequest, EndpointDbMethods } from '~/types';
|
||||
import { getModelMaxTokens, extractLibreChatParams, optionalChainWithEmptyCheck } from '~/utils';
|
||||
import { filterFilesByEndpointConfig } from '~/files';
|
||||
import { generateArtifactsPrompt } from '~/prompts';
|
||||
import { getProviderConfig } from '~/endpoints';
|
||||
import { primeResources } from './resources';
|
||||
|
||||
/**
|
||||
* Extended agent type with additional fields needed after initialization
|
||||
*/
|
||||
export type InitializedAgent = Agent & {
|
||||
tools: GenericTool[];
|
||||
attachments: IMongoFile[];
|
||||
toolContextMap: Record<string, unknown>;
|
||||
maxContextTokens: number;
|
||||
useLegacyContent: boolean;
|
||||
resendFiles: boolean;
|
||||
userMCPAuthMap?: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for initializing an agent
|
||||
* Matches the CJS signature from api/server/services/Endpoints/agents/agent.js
|
||||
*/
|
||||
export interface InitializeAgentParams {
|
||||
/** Request object */
|
||||
req: ServerRequest;
|
||||
/** Response object */
|
||||
res: ServerResponse;
|
||||
/** Agent to initialize */
|
||||
agent: Agent;
|
||||
/** Conversation ID (optional) */
|
||||
conversationId?: string | null;
|
||||
/** Request files */
|
||||
requestFiles?: IMongoFile[];
|
||||
/** Function to load agent tools */
|
||||
loadTools?: (params: {
|
||||
req: ServerRequest;
|
||||
res: ServerResponse;
|
||||
provider: string;
|
||||
agentId: string;
|
||||
tools: string[];
|
||||
model: string | null;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
}) => Promise<{
|
||||
tools: GenericTool[];
|
||||
toolContextMap: Record<string, unknown>;
|
||||
userMCPAuthMap?: Record<string, Record<string, string>>;
|
||||
} | null>;
|
||||
/** Endpoint option (contains model_parameters and endpoint info) */
|
||||
endpointOption?: Partial<TEndpointOption>;
|
||||
/** Set of allowed providers */
|
||||
allowedProviders: Set<string>;
|
||||
/** Whether this is the initial agent */
|
||||
isInitialAgent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Database methods required for agent initialization
|
||||
* Most methods come from data-schemas via createMethods()
|
||||
* getConvoFiles not yet in data-schemas but included here for consistency
|
||||
*/
|
||||
export interface InitializeAgentDbMethods extends EndpointDbMethods {
|
||||
/** Update usage tracking for multiple files */
|
||||
updateFilesUsage: (files: Array<{ file_id: string }>, fileIds?: string[]) => Promise<unknown[]>;
|
||||
/** Get files from database */
|
||||
getFiles: (filter: unknown, sort: unknown, select: unknown, opts?: unknown) => Promise<unknown[]>;
|
||||
/** Get tool files by IDs */
|
||||
getToolFilesByIds: (fileIds: string[], toolSet: Set<EToolResources>) => Promise<unknown[]>;
|
||||
/** Get conversation file IDs */
|
||||
getConvoFiles: (conversationId: string) => Promise<string[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an agent for use in requests.
|
||||
* Handles file processing, tool loading, provider configuration, and context token calculations.
|
||||
*
|
||||
* This function is exported from @librechat/api and replaces the CJS version from
|
||||
* api/server/services/Endpoints/agents/agent.js
|
||||
*
|
||||
* @param params - Initialization parameters
|
||||
* @param deps - Optional dependency injection for testing
|
||||
* @returns Promise resolving to initialized agent with tools and configuration
|
||||
* @throws Error if agent provider is not allowed or if required dependencies are missing
|
||||
*/
|
||||
export async function initializeAgent(
|
||||
params: InitializeAgentParams,
|
||||
db?: InitializeAgentDbMethods,
|
||||
): Promise<InitializedAgent> {
|
||||
const {
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
requestFiles = [],
|
||||
conversationId,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
isInitialAgent = false,
|
||||
} = params;
|
||||
|
||||
if (!db) {
|
||||
throw new Error('initializeAgent requires db methods to be passed');
|
||||
}
|
||||
|
||||
if (
|
||||
isAgentsEndpoint(endpointOption?.endpoint) &&
|
||||
allowedProviders.size > 0 &&
|
||||
!allowedProviders.has(agent.provider)
|
||||
) {
|
||||
throw new Error(
|
||||
`{ "type": "${ErrorTypes.INVALID_AGENT_PROVIDER}", "info": "${agent.provider}" }`,
|
||||
);
|
||||
}
|
||||
|
||||
let currentFiles: IMongoFile[] | undefined;
|
||||
|
||||
const _modelOptions = structuredClone(
|
||||
Object.assign(
|
||||
{ model: agent.model },
|
||||
agent.model_parameters ?? { model: agent.model },
|
||||
isInitialAgent === true ? endpointOption?.model_parameters : {},
|
||||
),
|
||||
);
|
||||
|
||||
const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(
|
||||
_modelOptions as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const provider = agent.provider;
|
||||
agent.endpoint = provider;
|
||||
|
||||
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||
const fileIds = (await db.getConvoFiles(conversationId)) ?? [];
|
||||
const toolResourceSet = new Set<EToolResources>();
|
||||
for (const tool of agent.tools ?? []) {
|
||||
if (EToolResources[tool as keyof typeof EToolResources]) {
|
||||
toolResourceSet.add(EToolResources[tool as keyof typeof EToolResources]);
|
||||
}
|
||||
}
|
||||
const toolFiles = (await db.getToolFilesByIds(fileIds, toolResourceSet)) as IMongoFile[];
|
||||
if (requestFiles.length || toolFiles.length) {
|
||||
currentFiles = (await db.updateFilesUsage(requestFiles.concat(toolFiles))) as IMongoFile[];
|
||||
}
|
||||
} else if (isInitialAgent && requestFiles.length) {
|
||||
currentFiles = (await db.updateFilesUsage(requestFiles)) as IMongoFile[];
|
||||
}
|
||||
|
||||
if (currentFiles && currentFiles.length) {
|
||||
let endpointType: EModelEndpoint | undefined;
|
||||
if (!paramEndpoints.has(agent.endpoint ?? '')) {
|
||||
endpointType = EModelEndpoint.custom;
|
||||
}
|
||||
|
||||
currentFiles = filterFilesByEndpointConfig(req, {
|
||||
files: currentFiles,
|
||||
endpoint: agent.endpoint ?? '',
|
||||
endpointType,
|
||||
});
|
||||
}
|
||||
|
||||
const { attachments: primedAttachments, tool_resources } = await primeResources({
|
||||
req: req as never,
|
||||
getFiles: db.getFiles as never,
|
||||
appConfig: req.config,
|
||||
agentId: agent.id,
|
||||
attachments: currentFiles
|
||||
? (Promise.resolve(currentFiles) as unknown as Promise<TFile[]>)
|
||||
: undefined,
|
||||
tool_resources: agent.tool_resources,
|
||||
requestFileSet: new Set(requestFiles?.map((file) => file.file_id)),
|
||||
});
|
||||
|
||||
const {
|
||||
tools: structuredTools,
|
||||
toolContextMap,
|
||||
userMCPAuthMap,
|
||||
} = (await loadTools?.({
|
||||
req,
|
||||
res,
|
||||
provider,
|
||||
agentId: agent.id,
|
||||
tools: agent.tools ?? [],
|
||||
model: agent.model,
|
||||
tool_resources,
|
||||
})) ?? { tools: [], toolContextMap: {}, userMCPAuthMap: undefined };
|
||||
|
||||
const { getOptions, overrideProvider } = getProviderConfig({
|
||||
provider,
|
||||
appConfig: req.config,
|
||||
});
|
||||
if (overrideProvider !== agent.provider) {
|
||||
agent.provider = overrideProvider;
|
||||
}
|
||||
|
||||
const finalModelOptions = {
|
||||
...modelOptions,
|
||||
model: agent.model,
|
||||
};
|
||||
|
||||
const options: InitializeResultBase = await getOptions({
|
||||
req,
|
||||
endpoint: provider,
|
||||
model_parameters: finalModelOptions,
|
||||
db,
|
||||
});
|
||||
|
||||
const llmConfig = options.llmConfig as Record<string, unknown>;
|
||||
const tokensModel =
|
||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : (llmConfig?.model as string);
|
||||
const maxOutputTokens = optionalChainWithEmptyCheck(
|
||||
llmConfig?.maxOutputTokens as number | undefined,
|
||||
llmConfig?.maxTokens as number | undefined,
|
||||
0,
|
||||
);
|
||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||
maxContextTokens,
|
||||
getModelMaxTokens(
|
||||
tokensModel ?? '',
|
||||
providerEndpointMap[provider as keyof typeof providerEndpointMap],
|
||||
options.endpointTokenConfig,
|
||||
),
|
||||
18000,
|
||||
);
|
||||
|
||||
if (
|
||||
agent.endpoint === EModelEndpoint.azureOpenAI &&
|
||||
(llmConfig?.azureOpenAIApiInstanceName as string | undefined) == null
|
||||
) {
|
||||
agent.provider = Providers.OPENAI;
|
||||
}
|
||||
|
||||
if (options.provider != null) {
|
||||
agent.provider = options.provider;
|
||||
}
|
||||
|
||||
let tools: GenericTool[] = options.tools?.length
|
||||
? (options.tools as GenericTool[])
|
||||
: structuredTools;
|
||||
if (
|
||||
(agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) &&
|
||||
options.tools?.length &&
|
||||
structuredTools?.length
|
||||
) {
|
||||
throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`);
|
||||
} else if (
|
||||
(agent.provider === Providers.OPENAI ||
|
||||
agent.provider === Providers.AZURE ||
|
||||
agent.provider === Providers.ANTHROPIC) &&
|
||||
options.tools?.length &&
|
||||
structuredTools?.length
|
||||
) {
|
||||
tools = structuredTools.concat(options.tools as GenericTool[]);
|
||||
}
|
||||
|
||||
agent.model_parameters = { ...options.llmConfig } as Agent['model_parameters'];
|
||||
if (options.configOptions) {
|
||||
(agent.model_parameters as Record<string, unknown>).configuration = options.configOptions;
|
||||
}
|
||||
|
||||
if (agent.instructions && agent.instructions !== '') {
|
||||
agent.instructions = replaceSpecialVars({
|
||||
text: agent.instructions,
|
||||
user: req.user ? (req.user as unknown as TUser) : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
|
||||
const artifactsPromptResult = generateArtifactsPrompt({
|
||||
endpoint: agent.provider,
|
||||
artifacts: agent.artifacts as never,
|
||||
});
|
||||
agent.additional_instructions = artifactsPromptResult ?? undefined;
|
||||
}
|
||||
|
||||
const agentMaxContextNum = Number(agentMaxContextTokens) || 18000;
|
||||
const maxOutputTokensNum = Number(maxOutputTokens) || 0;
|
||||
|
||||
const finalAttachments: IMongoFile[] = (primedAttachments ?? [])
|
||||
.filter((a): a is TFile => a != null)
|
||||
.map((a) => a as unknown as IMongoFile);
|
||||
|
||||
const initializedAgent: InitializedAgent = {
|
||||
...agent,
|
||||
tools: (tools ?? []) as GenericTool[] & string[],
|
||||
attachments: finalAttachments,
|
||||
resendFiles,
|
||||
userMCPAuthMap,
|
||||
toolContextMap: toolContextMap ?? {},
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: Math.round((agentMaxContextNum - maxOutputTokensNum) * 0.9),
|
||||
};
|
||||
|
||||
return initializedAgent;
|
||||
}
|
||||
|
|
@ -152,7 +152,7 @@ export const primeResources = async ({
|
|||
agentId,
|
||||
}: {
|
||||
req: ServerRequest & { user?: IUser };
|
||||
appConfig: AppConfig;
|
||||
appConfig?: AppConfig;
|
||||
requestFileSet: Set<string>;
|
||||
attachments: Promise<Array<TFile | null>> | undefined;
|
||||
tool_resources: AgentToolResources | undefined;
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
import 'dotenv/config';
|
||||
import crypto from 'node:crypto';
|
||||
const { webcrypto } = crypto;
|
||||
|
||||
// Use hex decoding for both key and IV for legacy methods.
|
||||
const key = Buffer.from(process.env.CREDS_KEY ?? '', 'hex');
|
||||
const iv = Buffer.from(process.env.CREDS_IV ?? '', 'hex');
|
||||
const algorithm = 'AES-CBC';
|
||||
|
||||
// --- Legacy v1/v2 Setup: AES-CBC with fixed key and IV ---
|
||||
|
||||
export async function encrypt(value: string) {
|
||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||
'encrypt',
|
||||
]);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(value);
|
||||
const encryptedBuffer = await webcrypto.subtle.encrypt(
|
||||
{ name: algorithm, iv: iv },
|
||||
cryptoKey,
|
||||
data,
|
||||
);
|
||||
return Buffer.from(encryptedBuffer).toString('hex');
|
||||
}
|
||||
|
||||
export async function decrypt(encryptedValue: string) {
|
||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||
'decrypt',
|
||||
]);
|
||||
const encryptedBuffer = Buffer.from(encryptedValue, 'hex');
|
||||
const decryptedBuffer = await webcrypto.subtle.decrypt(
|
||||
{ name: algorithm, iv: iv },
|
||||
cryptoKey,
|
||||
encryptedBuffer,
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
// --- v2: AES-CBC with a random IV per encryption ---
|
||||
|
||||
export async function encryptV2(value: string) {
|
||||
const gen_iv = webcrypto.getRandomValues(new Uint8Array(16));
|
||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||
'encrypt',
|
||||
]);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(value);
|
||||
const encryptedBuffer = await webcrypto.subtle.encrypt(
|
||||
{ name: algorithm, iv: gen_iv },
|
||||
cryptoKey,
|
||||
data,
|
||||
);
|
||||
return Buffer.from(gen_iv).toString('hex') + ':' + Buffer.from(encryptedBuffer).toString('hex');
|
||||
}
|
||||
|
||||
export async function decryptV2(encryptedValue: string) {
|
||||
const parts = encryptedValue.split(':');
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
const gen_iv = Buffer.from(parts.shift() ?? '', 'hex');
|
||||
const encrypted = parts.join(':');
|
||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||
'decrypt',
|
||||
]);
|
||||
const encryptedBuffer = Buffer.from(encrypted, 'hex');
|
||||
const decryptedBuffer = await webcrypto.subtle.decrypt(
|
||||
{ name: algorithm, iv: gen_iv },
|
||||
cryptoKey,
|
||||
encryptedBuffer,
|
||||
);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
// --- v3: AES-256-CTR using Node's crypto functions ---
|
||||
const algorithm_v3 = 'aes-256-ctr';
|
||||
|
||||
/**
|
||||
* Encrypts a value using AES-256-CTR.
|
||||
* Note: AES-256 requires a 32-byte key. Ensure that process.env.CREDS_KEY is a 64-character hex string.
|
||||
*
|
||||
* @param value - The plaintext to encrypt.
|
||||
* @returns The encrypted string with a "v3:" prefix.
|
||||
*/
|
||||
export function encryptV3(value: string) {
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length} bytes`);
|
||||
}
|
||||
const iv_v3 = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm_v3, key, iv_v3);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
return `v3:${iv_v3.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decryptV3(encryptedValue: string) {
|
||||
const parts = encryptedValue.split(':');
|
||||
if (parts[0] !== 'v3') {
|
||||
throw new Error('Not a v3 encrypted value');
|
||||
}
|
||||
const iv_v3 = Buffer.from(parts[1], 'hex');
|
||||
const encryptedText = Buffer.from(parts.slice(2).join(':'), 'hex');
|
||||
const decipher = crypto.createDecipheriv(algorithm_v3, key, iv_v3);
|
||||
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
export async function getRandomValues(length: number) {
|
||||
if (!Number.isInteger(length) || length <= 0) {
|
||||
throw new Error('Length must be a positive integer');
|
||||
}
|
||||
const randomValues = new Uint8Array(length);
|
||||
webcrypto.getRandomValues(randomValues);
|
||||
return Buffer.from(randomValues).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash for the given input.
|
||||
* @param input - The input to hash.
|
||||
* @returns The SHA-256 hash of the input.
|
||||
*/
|
||||
export async function hashBackupCode(input: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
|
@ -1,2 +1,11 @@
|
|||
export * from './encryption';
|
||||
export {
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptV2,
|
||||
decryptV2,
|
||||
encryptV3,
|
||||
decryptV3,
|
||||
hashBackupCode,
|
||||
getRandomValues,
|
||||
} from '@librechat/data-schemas';
|
||||
export * from './jwt';
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './helpers';
|
||||
export * from './llm';
|
||||
export * from './initialize';
|
||||
|
|
|
|||
73
packages/api/src/endpoints/anthropic/initialize.ts
Normal file
73
packages/api/src/endpoints/anthropic/initialize.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { BaseInitializeParams, InitializeResultBase, AnthropicConfigOptions } from '~/types';
|
||||
import { checkUserKeyExpiry } from '~/utils';
|
||||
import { getLLMConfig } from './llm';
|
||||
|
||||
/**
|
||||
* Initializes Anthropic endpoint configuration.
|
||||
*
|
||||
* @param params - Configuration parameters
|
||||
* @returns Promise resolving to Anthropic configuration options
|
||||
* @throws Error if API key is not provided
|
||||
*/
|
||||
export async function initializeAnthropic({
|
||||
req,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
db,
|
||||
}: BaseInitializeParams): Promise<InitializeResultBase> {
|
||||
void endpoint;
|
||||
const appConfig = req.config;
|
||||
const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env;
|
||||
const { key: expiresAt } = req.body;
|
||||
const isUserProvided = ANTHROPIC_API_KEY === 'user_provided';
|
||||
|
||||
const anthropicApiKey = isUserProvided
|
||||
? await db.getUserKey({ userId: req.user?.id ?? '', name: EModelEndpoint.anthropic })
|
||||
: ANTHROPIC_API_KEY;
|
||||
|
||||
if (!anthropicApiKey) {
|
||||
throw new Error('Anthropic API key not provided. Please provide it again.');
|
||||
}
|
||||
|
||||
if (expiresAt && isUserProvided) {
|
||||
checkUserKeyExpiry(expiresAt, EModelEndpoint.anthropic);
|
||||
}
|
||||
|
||||
let clientOptions: AnthropicConfigOptions = {};
|
||||
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
const anthropicConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic];
|
||||
|
||||
if (anthropicConfig) {
|
||||
clientOptions = {
|
||||
...clientOptions,
|
||||
// Note: _lc_stream_delay is set on modelOptions in the result
|
||||
};
|
||||
}
|
||||
|
||||
const allConfig = appConfig?.endpoints?.all;
|
||||
|
||||
clientOptions = {
|
||||
proxy: PROXY ?? undefined,
|
||||
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? undefined,
|
||||
modelOptions: {
|
||||
...(model_parameters ?? {}),
|
||||
user: req.user?.id,
|
||||
},
|
||||
...clientOptions,
|
||||
};
|
||||
|
||||
const result = getLLMConfig(anthropicApiKey, clientOptions);
|
||||
|
||||
// Apply stream rate delay
|
||||
if (anthropicConfig?.streamRate) {
|
||||
(result.llmConfig as Record<string, unknown>)._lc_stream_delay = anthropicConfig.streamRate;
|
||||
}
|
||||
|
||||
if (allConfig?.streamRate) {
|
||||
(result.llmConfig as Record<string, unknown>)._lc_stream_delay = allConfig.streamRate;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
1
packages/api/src/endpoints/bedrock/index.ts
Normal file
1
packages/api/src/endpoints/bedrock/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './initialize';
|
||||
148
packages/api/src/endpoints/bedrock/initialize.ts
Normal file
148
packages/api/src/endpoints/bedrock/initialize.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
|
||||
import {
|
||||
AuthType,
|
||||
EModelEndpoint,
|
||||
bedrockInputParser,
|
||||
bedrockOutputParser,
|
||||
removeNullishValues,
|
||||
} from 'librechat-data-provider';
|
||||
import type { BaseInitializeParams, InitializeResultBase, BedrockCredentials } from '~/types';
|
||||
import { checkUserKeyExpiry } from '~/utils';
|
||||
|
||||
/**
|
||||
* Initializes Bedrock endpoint configuration.
|
||||
*
|
||||
* This module handles configuration for AWS Bedrock endpoints, including support for
|
||||
* HTTP/HTTPS proxies and reverse proxies.
|
||||
*
|
||||
* Proxy Support:
|
||||
* - When the PROXY environment variable is set, creates a custom BedrockRuntimeClient
|
||||
* with an HttpsProxyAgent to route all Bedrock API calls through the specified proxy
|
||||
* - The custom client is fully configured with credentials, region, and endpoint,
|
||||
* and is passed directly to ChatBedrockConverse via the 'client' parameter
|
||||
*
|
||||
* Reverse Proxy Support:
|
||||
* - When BEDROCK_REVERSE_PROXY is set, routes Bedrock API calls through a custom endpoint
|
||||
* - Works with or without the PROXY setting
|
||||
*
|
||||
* Without Proxy:
|
||||
* - Credentials and endpoint configuration are passed separately to ChatBedrockConverse,
|
||||
* which creates its own BedrockRuntimeClient internally
|
||||
*
|
||||
* @param params - Configuration parameters
|
||||
* @returns Promise resolving to Bedrock configuration options
|
||||
* @throws Error if credentials are not provided when required
|
||||
*/
|
||||
export async function initializeBedrock({
|
||||
req,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
db,
|
||||
}: BaseInitializeParams): Promise<InitializeResultBase> {
|
||||
void endpoint;
|
||||
const {
|
||||
BEDROCK_AWS_SECRET_ACCESS_KEY,
|
||||
BEDROCK_AWS_ACCESS_KEY_ID,
|
||||
BEDROCK_AWS_SESSION_TOKEN,
|
||||
BEDROCK_REVERSE_PROXY,
|
||||
BEDROCK_AWS_DEFAULT_REGION,
|
||||
PROXY,
|
||||
} = process.env;
|
||||
|
||||
const { key: expiresAt } = req.body;
|
||||
const isUserProvided = BEDROCK_AWS_SECRET_ACCESS_KEY === AuthType.USER_PROVIDED;
|
||||
|
||||
let credentials: BedrockCredentials | undefined = isUserProvided
|
||||
? await db
|
||||
.getUserKey({ userId: req.user?.id ?? '', name: EModelEndpoint.bedrock })
|
||||
.then((key) => JSON.parse(key) as BedrockCredentials)
|
||||
: {
|
||||
accessKeyId: BEDROCK_AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: BEDROCK_AWS_SECRET_ACCESS_KEY,
|
||||
...(BEDROCK_AWS_SESSION_TOKEN && { sessionToken: BEDROCK_AWS_SESSION_TOKEN }),
|
||||
};
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error('Bedrock credentials not provided. Please provide them again.');
|
||||
}
|
||||
|
||||
if (
|
||||
!isUserProvided &&
|
||||
(credentials.accessKeyId === undefined || credentials.accessKeyId === '') &&
|
||||
(credentials.secretAccessKey === undefined || credentials.secretAccessKey === '')
|
||||
) {
|
||||
credentials = undefined;
|
||||
}
|
||||
|
||||
if (expiresAt && isUserProvided) {
|
||||
checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock);
|
||||
}
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
model: model_parameters?.model as string | undefined,
|
||||
region: BEDROCK_AWS_DEFAULT_REGION,
|
||||
};
|
||||
|
||||
const configOptions: Record<string, unknown> = {};
|
||||
|
||||
const llmConfig = bedrockOutputParser(
|
||||
bedrockInputParser.parse(
|
||||
removeNullishValues({ ...requestOptions, ...(model_parameters ?? {}) }),
|
||||
),
|
||||
) as InitializeResultBase['llmConfig'] & {
|
||||
region?: string;
|
||||
client?: BedrockRuntimeClient;
|
||||
credentials?: BedrockCredentials;
|
||||
endpointHost?: string;
|
||||
};
|
||||
|
||||
/** Only include credentials if they're complete (accessKeyId and secretAccessKey are both set) */
|
||||
const hasCompleteCredentials =
|
||||
credentials &&
|
||||
typeof credentials.accessKeyId === 'string' &&
|
||||
credentials.accessKeyId !== '' &&
|
||||
typeof credentials.secretAccessKey === 'string' &&
|
||||
credentials.secretAccessKey !== '';
|
||||
|
||||
if (PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(PROXY);
|
||||
|
||||
// Create a custom BedrockRuntimeClient with proxy-enabled request handler.
|
||||
// ChatBedrockConverse will use this pre-configured client directly instead of
|
||||
// creating its own. Credentials are only set if explicitly provided; otherwise
|
||||
// the AWS SDK's default credential provider chain is used (instance profiles,
|
||||
// AWS profiles, environment variables, etc.)
|
||||
const customClient = new BedrockRuntimeClient({
|
||||
region: (llmConfig.region as string) ?? BEDROCK_AWS_DEFAULT_REGION,
|
||||
...(hasCompleteCredentials && {
|
||||
credentials: credentials as { accessKeyId: string; secretAccessKey: string },
|
||||
}),
|
||||
requestHandler: new NodeHttpHandler({
|
||||
httpAgent: proxyAgent,
|
||||
httpsAgent: proxyAgent,
|
||||
}),
|
||||
...(BEDROCK_REVERSE_PROXY && {
|
||||
endpoint: `https://${BEDROCK_REVERSE_PROXY}`,
|
||||
}),
|
||||
});
|
||||
|
||||
llmConfig.client = customClient;
|
||||
} else {
|
||||
// When not using a proxy, let ChatBedrockConverse create its own client
|
||||
// by providing credentials and endpoint separately
|
||||
if (credentials) {
|
||||
llmConfig.credentials = credentials;
|
||||
}
|
||||
|
||||
if (BEDROCK_REVERSE_PROXY) {
|
||||
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmConfig,
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
99
packages/api/src/endpoints/config.ts
Normal file
99
packages/api/src/endpoints/config.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { BaseInitializeParams, InitializeResultBase } from '~/types';
|
||||
import { initializeAnthropic } from './anthropic/initialize';
|
||||
import { initializeBedrock } from './bedrock/initialize';
|
||||
import { initializeCustom } from './custom/initialize';
|
||||
import { initializeGoogle } from './google/initialize';
|
||||
import { initializeOpenAI } from './openai/initialize';
|
||||
import { getCustomEndpointConfig } from '~/app/config';
|
||||
|
||||
/**
|
||||
* Type for initialize functions
|
||||
*/
|
||||
export type InitializeFn = (params: BaseInitializeParams) => Promise<InitializeResultBase>;
|
||||
|
||||
/**
|
||||
* Check if the provider is a known custom provider
|
||||
* @param provider - The provider string
|
||||
* @returns True if the provider is a known custom provider, false otherwise
|
||||
*/
|
||||
export function isKnownCustomProvider(provider?: string): boolean {
|
||||
return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
||||
(provider?.toLowerCase() ?? '') as Providers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration map mapping providers to their initialization functions
|
||||
*/
|
||||
export const providerConfigMap: Record<string, InitializeFn> = {
|
||||
[Providers.XAI]: initializeCustom,
|
||||
[Providers.DEEPSEEK]: initializeCustom,
|
||||
[Providers.OPENROUTER]: initializeCustom,
|
||||
[EModelEndpoint.openAI]: initializeOpenAI,
|
||||
[EModelEndpoint.google]: initializeGoogle,
|
||||
[EModelEndpoint.bedrock]: initializeBedrock,
|
||||
[EModelEndpoint.azureOpenAI]: initializeOpenAI,
|
||||
[EModelEndpoint.anthropic]: initializeAnthropic,
|
||||
};
|
||||
|
||||
/**
|
||||
* Result from getProviderConfig
|
||||
*/
|
||||
export interface ProviderConfigResult {
|
||||
/** The initialization function for this provider */
|
||||
getOptions: InitializeFn;
|
||||
/** The resolved provider name (may be different from input if normalized) */
|
||||
overrideProvider: string;
|
||||
/** Custom endpoint configuration (if applicable) */
|
||||
customEndpointConfig?: Partial<TEndpoint>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider configuration and override endpoint based on the provider string
|
||||
*
|
||||
* @param params - Configuration parameters
|
||||
* @param params.provider - The provider string
|
||||
* @param params.appConfig - The application configuration
|
||||
* @returns Provider configuration including getOptions function, override provider, and custom config
|
||||
* @throws Error if provider is not supported
|
||||
*/
|
||||
export function getProviderConfig({
|
||||
provider,
|
||||
appConfig,
|
||||
}: {
|
||||
provider: string;
|
||||
appConfig?: AppConfig;
|
||||
}): ProviderConfigResult {
|
||||
let getOptions = providerConfigMap[provider];
|
||||
let overrideProvider = provider;
|
||||
let customEndpointConfig: Partial<TEndpoint> | undefined;
|
||||
|
||||
if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) {
|
||||
overrideProvider = provider.toLowerCase();
|
||||
getOptions = providerConfigMap[overrideProvider];
|
||||
} else if (!getOptions) {
|
||||
customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig });
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
}
|
||||
getOptions = initializeCustom;
|
||||
overrideProvider = Providers.OPENAI;
|
||||
}
|
||||
|
||||
if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) {
|
||||
customEndpointConfig = getCustomEndpointConfig({ endpoint: provider, appConfig });
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getOptions,
|
||||
overrideProvider,
|
||||
customEndpointConfig,
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './config';
|
||||
export * from './initialize';
|
||||
|
|
|
|||
180
packages/api/src/endpoints/custom/initialize.ts
Normal file
180
packages/api/src/endpoints/custom/initialize.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import {
|
||||
CacheKeys,
|
||||
ErrorTypes,
|
||||
envVarRegex,
|
||||
FetchTokenConfig,
|
||||
extractEnvVariable,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TEndpoint } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { BaseInitializeParams, InitializeResultBase, EndpointTokenConfig } from '~/types';
|
||||
import { getOpenAIConfig } from '~/endpoints/openai/config';
|
||||
import { getCustomEndpointConfig } from '~/app/config';
|
||||
import { fetchModels } from '~/endpoints/models';
|
||||
import { isUserProvided, checkUserKeyExpiry } from '~/utils';
|
||||
import { standardCache } from '~/cache';
|
||||
|
||||
const { PROXY } = process.env;
|
||||
|
||||
/**
|
||||
* Builds custom options from endpoint configuration
|
||||
*/
|
||||
function buildCustomOptions(
|
||||
endpointConfig: Partial<TEndpoint>,
|
||||
appConfig?: AppConfig,
|
||||
endpointTokenConfig?: Record<string, unknown>,
|
||||
) {
|
||||
const customOptions: Record<string, unknown> = {
|
||||
headers: endpointConfig.headers,
|
||||
addParams: endpointConfig.addParams,
|
||||
dropParams: endpointConfig.dropParams,
|
||||
customParams: endpointConfig.customParams,
|
||||
titleConvo: endpointConfig.titleConvo,
|
||||
titleModel: endpointConfig.titleModel,
|
||||
forcePrompt: endpointConfig.forcePrompt,
|
||||
summaryModel: endpointConfig.summaryModel,
|
||||
modelDisplayLabel: endpointConfig.modelDisplayLabel,
|
||||
titleMethod: endpointConfig.titleMethod ?? 'completion',
|
||||
contextStrategy: endpointConfig.summarize ? 'summarize' : null,
|
||||
directEndpoint: endpointConfig.directEndpoint,
|
||||
titleMessageRole: endpointConfig.titleMessageRole,
|
||||
streamRate: endpointConfig.streamRate,
|
||||
endpointTokenConfig,
|
||||
};
|
||||
|
||||
const allConfig = appConfig?.endpoints?.all;
|
||||
if (allConfig) {
|
||||
customOptions.streamRate = allConfig.streamRate;
|
||||
}
|
||||
|
||||
return customOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a custom endpoint client configuration.
|
||||
* This function handles custom endpoints defined in librechat.yaml, including
|
||||
* user-provided API keys and URLs.
|
||||
*
|
||||
* @param params - Configuration parameters
|
||||
* @returns Promise resolving to endpoint configuration options
|
||||
* @throws Error if config is missing, API key is not provided, or base URL is missing
|
||||
*/
|
||||
export async function initializeCustom({
|
||||
req,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
db,
|
||||
}: BaseInitializeParams): Promise<InitializeResultBase> {
|
||||
const appConfig = req.config;
|
||||
const { key: expiresAt } = req.body;
|
||||
|
||||
const endpointConfig = getCustomEndpointConfig({
|
||||
endpoint,
|
||||
appConfig,
|
||||
});
|
||||
|
||||
if (!endpointConfig) {
|
||||
throw new Error(`Config not found for the ${endpoint} custom endpoint.`);
|
||||
}
|
||||
|
||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey ?? '');
|
||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL ?? '');
|
||||
|
||||
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||
}
|
||||
|
||||
if (CUSTOM_BASE_URL.match(envVarRegex)) {
|
||||
throw new Error(`Missing Base URL for ${endpoint}.`);
|
||||
}
|
||||
|
||||
const userProvidesKey = isUserProvided(CUSTOM_API_KEY);
|
||||
const userProvidesURL = isUserProvided(CUSTOM_BASE_URL);
|
||||
|
||||
let userValues = null;
|
||||
if (expiresAt && (userProvidesKey || userProvidesURL)) {
|
||||
checkUserKeyExpiry(expiresAt, endpoint);
|
||||
userValues = await db.getUserKeyValues({ userId: req.user?.id ?? '', name: endpoint });
|
||||
}
|
||||
|
||||
const apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
|
||||
const baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
|
||||
|
||||
if (userProvidesKey && !apiKey) {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
type: ErrorTypes.NO_USER_KEY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (userProvidesURL && !baseURL) {
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
type: ErrorTypes.NO_BASE_URL,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`${endpoint} API key not provided.`);
|
||||
}
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error(`${endpoint} Base URL not provided.`);
|
||||
}
|
||||
|
||||
let endpointTokenConfig: EndpointTokenConfig | undefined;
|
||||
|
||||
const userId = req.user?.id ?? '';
|
||||
|
||||
const cache = standardCache(CacheKeys.TOKEN_CONFIG);
|
||||
/** tokenConfig is an optional extended property on custom endpoints */
|
||||
const hasTokenConfig = (endpointConfig as Record<string, unknown>).tokenConfig != null;
|
||||
const tokenKey =
|
||||
!hasTokenConfig && (userProvidesKey || userProvidesURL) ? `${endpoint}:${userId}` : endpoint;
|
||||
|
||||
const cachedConfig =
|
||||
!hasTokenConfig &&
|
||||
FetchTokenConfig[endpoint.toLowerCase() as keyof typeof FetchTokenConfig] &&
|
||||
(await cache.get(tokenKey));
|
||||
|
||||
endpointTokenConfig = (cachedConfig as EndpointTokenConfig) || undefined;
|
||||
|
||||
if (
|
||||
FetchTokenConfig[endpoint.toLowerCase() as keyof typeof FetchTokenConfig] &&
|
||||
endpointConfig &&
|
||||
endpointConfig.models?.fetch &&
|
||||
!endpointTokenConfig
|
||||
) {
|
||||
await fetchModels({ apiKey, baseURL, name: endpoint, user: userId, tokenKey });
|
||||
endpointTokenConfig = (await cache.get(tokenKey)) as EndpointTokenConfig | undefined;
|
||||
}
|
||||
|
||||
const customOptions = buildCustomOptions(endpointConfig, appConfig, endpointTokenConfig);
|
||||
|
||||
const clientOptions: Record<string, unknown> = {
|
||||
reverseProxyUrl: baseURL ?? null,
|
||||
proxy: PROXY ?? null,
|
||||
...customOptions,
|
||||
};
|
||||
|
||||
const modelOptions = { ...(model_parameters ?? {}), user: userId };
|
||||
const finalClientOptions = {
|
||||
modelOptions,
|
||||
...clientOptions,
|
||||
};
|
||||
|
||||
const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint);
|
||||
if (options != null) {
|
||||
(options as InitializeResultBase).useLegacyContent = true;
|
||||
(options as InitializeResultBase).endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
|
||||
const streamRate = clientOptions.streamRate as number | undefined;
|
||||
if (streamRate) {
|
||||
(options.llmConfig as Record<string, unknown>)._lc_stream_delay = streamRate;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './llm';
|
||||
export * from './initialize';
|
||||
|
|
|
|||
91
packages/api/src/endpoints/google/initialize.ts
Normal file
91
packages/api/src/endpoints/google/initialize.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import path from 'path';
|
||||
import { EModelEndpoint, AuthKeys } from 'librechat-data-provider';
|
||||
import type {
|
||||
BaseInitializeParams,
|
||||
InitializeResultBase,
|
||||
GoogleConfigOptions,
|
||||
GoogleCredentials,
|
||||
} from '~/types';
|
||||
import { isEnabled, loadServiceKey, checkUserKeyExpiry } from '~/utils';
|
||||
import { getGoogleConfig } from './llm';
|
||||
|
||||
/**
|
||||
* Initializes Google/Vertex AI endpoint configuration.
|
||||
* Supports both API key authentication and service account credentials.
|
||||
*
|
||||
* @param params - Configuration parameters
|
||||
* @returns Promise resolving to Google configuration options
|
||||
* @throws Error if no valid credentials are provided
|
||||
*/
|
||||
export async function initializeGoogle({
|
||||
req,
|
||||
endpoint,
|
||||
model_parameters,
|
||||
db,
|
||||
}: BaseInitializeParams): Promise<InitializeResultBase> {
|
||||
void endpoint;
|
||||
const appConfig = req.config;
|
||||
const { GOOGLE_KEY, GOOGLE_REVERSE_PROXY, GOOGLE_AUTH_HEADER, PROXY } = process.env;
|
||||
const isUserProvided = GOOGLE_KEY === 'user_provided';
|
||||
const { key: expiresAt } = req.body;
|
||||
|
||||
let userKey = null;
|
||||
if (expiresAt && isUserProvided) {
|
||||
checkUserKeyExpiry(expiresAt, EModelEndpoint.google);
|
||||
userKey = await db.getUserKey({ userId: req.user?.id, name: EModelEndpoint.google });
|
||||
}
|
||||
|
||||
let serviceKey: Record<string, unknown> = {};
|
||||
|
||||
/** Check if GOOGLE_KEY is provided at all (including 'user_provided') */
|
||||
const isGoogleKeyProvided =
|
||||
(GOOGLE_KEY && GOOGLE_KEY.trim() !== '') || (isUserProvided && userKey != null);
|
||||
|
||||
if (!isGoogleKeyProvided && loadServiceKey) {
|
||||
/** Only attempt to load service key if GOOGLE_KEY is not provided */
|
||||
try {
|
||||
const serviceKeyPath =
|
||||
process.env.GOOGLE_SERVICE_KEY_FILE || path.join(process.cwd(), 'data', 'auth.json');
|
||||
const loadedKey = await loadServiceKey(serviceKeyPath);
|
||||
if (loadedKey) {
|
||||
serviceKey = loadedKey;
|
||||
}
|
||||
} catch {
|
||||
// Service key loading failed, but that's okay if not required
|
||||
serviceKey = {};
|
||||
}
|
||||
}
|
||||
|
||||
const credentials: GoogleCredentials = isUserProvided
|
||||
? (userKey as GoogleCredentials)
|
||||
: {
|
||||
[AuthKeys.GOOGLE_SERVICE_KEY]: serviceKey,
|
||||
[AuthKeys.GOOGLE_API_KEY]: GOOGLE_KEY,
|
||||
};
|
||||
|
||||
let clientOptions: GoogleConfigOptions = {};
|
||||
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
const allConfig = appConfig?.endpoints?.all;
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
const googleConfig = appConfig?.endpoints?.[EModelEndpoint.google];
|
||||
|
||||
if (googleConfig) {
|
||||
clientOptions.streamRate = googleConfig.streamRate;
|
||||
clientOptions.titleModel = googleConfig.titleModel;
|
||||
}
|
||||
|
||||
if (allConfig) {
|
||||
clientOptions.streamRate = allConfig.streamRate;
|
||||
}
|
||||
|
||||
clientOptions = {
|
||||
reverseProxyUrl: GOOGLE_REVERSE_PROXY ?? undefined,
|
||||
authHeader: isEnabled(GOOGLE_AUTH_HEADER) ?? undefined,
|
||||
proxy: PROXY ?? undefined,
|
||||
modelOptions: model_parameters ?? {},
|
||||
...clientOptions,
|
||||
};
|
||||
|
||||
return getGoogleConfig(credentials, clientOptions);
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
export * from './anthropic';
|
||||
export * from './bedrock';
|
||||
export * from './config';
|
||||
export * from './custom';
|
||||
export * from './google';
|
||||
export * from './models';
|
||||
export * from './openai';
|
||||
export * from './anthropic';
|
||||
|
|
|
|||
628
packages/api/src/endpoints/models.spec.ts
Normal file
628
packages/api/src/endpoints/models.spec.ts
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
import axios from 'axios';
|
||||
import { EModelEndpoint, defaultModels } from 'librechat-data-provider';
|
||||
import {
|
||||
fetchModels,
|
||||
splitAndTrim,
|
||||
getOpenAIModels,
|
||||
getGoogleModels,
|
||||
getBedrockModels,
|
||||
getAnthropicModels,
|
||||
} from './models';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
standardCache: jest.fn().mockImplementation(() => ({
|
||||
get: jest.fn().mockResolvedValue(undefined),
|
||||
set: jest.fn().mockResolvedValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => {
|
||||
const originalUtils = jest.requireActual('~/utils');
|
||||
return {
|
||||
...originalUtils,
|
||||
processModelData: jest.fn((...args) => originalUtils.processModelData(...args)),
|
||||
logAxiosError: jest.fn(),
|
||||
resolveHeaders: jest.fn((options) => options?.headers || {}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
const { logAxiosError, resolveHeaders } = jest.requireMock('~/utils');
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 'model-1' }, { id: 'model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
describe('fetchModels', () => {
|
||||
it('fetches models successfully from the API', async () => {
|
||||
const models = await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['model-1', 'model-2']);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.test.com/models'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the user ID to the models query when option and ID are passed', async () => {
|
||||
const models = await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
userIdQuery: true,
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['model-1', 'model-2']);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.test.com/models?user=user123'),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers to the API request', async () => {
|
||||
const customHeaders = {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'X-API-Version': 'v2',
|
||||
};
|
||||
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
name: 'TestAPI',
|
||||
headers: customHeaders,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.test.com/models'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'X-API-Version': 'v2',
|
||||
Authorization: 'Bearer testApiKey',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null headers gracefully', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
name: 'TestAPI',
|
||||
headers: null,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.test.com/models'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer testApiKey',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined headers gracefully', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
name: 'TestAPI',
|
||||
headers: undefined,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.test.com/models'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer testApiKey',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchModels with createTokenConfig true', () => {
|
||||
const data = {
|
||||
data: [
|
||||
{
|
||||
id: 'model-1',
|
||||
pricing: {
|
||||
prompt: '0.002',
|
||||
completion: '0.001',
|
||||
},
|
||||
context_length: 1024,
|
||||
},
|
||||
{
|
||||
id: 'model-2',
|
||||
pricing: {
|
||||
prompt: '0.003',
|
||||
completion: '0.0015',
|
||||
},
|
||||
context_length: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedAxios.get.mockResolvedValue({ data });
|
||||
});
|
||||
|
||||
it('creates and stores token configuration if createTokenConfig is true', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
createTokenConfig: true,
|
||||
});
|
||||
|
||||
const { processModelData } = jest.requireMock('~/utils');
|
||||
expect(processModelData).toHaveBeenCalled();
|
||||
expect(processModelData).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenAIModels', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
mockedAxios.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns default models when no environment configurations are provided (and fetch fails)', async () => {
|
||||
const models = await getOpenAIModels({ user: 'user456' });
|
||||
expect(models).toContain('gpt-4');
|
||||
});
|
||||
|
||||
it('returns `AZURE_OPENAI_MODELS` with `azure` flag (and fetch fails)', async () => {
|
||||
process.env.AZURE_OPENAI_MODELS = 'azure-model,azure-model-2';
|
||||
const models = await getOpenAIModels({ azure: true });
|
||||
expect(models).toEqual(expect.arrayContaining(['azure-model', 'azure-model-2']));
|
||||
});
|
||||
|
||||
it('returns `OPENAI_MODELS` with no flags (and fetch fails)', async () => {
|
||||
process.env.OPENAI_MODELS = 'openai-model,openai-model-2';
|
||||
const models = await getOpenAIModels({});
|
||||
expect(models).toEqual(expect.arrayContaining(['openai-model', 'openai-model-2']));
|
||||
});
|
||||
|
||||
it('utilizes proxy configuration when PROXY is set', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
process.env.PROXY = 'http://localhost:8888';
|
||||
process.env.OPENAI_API_KEY = 'mockedApiKey';
|
||||
await getOpenAIModels({ user: 'user456' });
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
httpsAgent: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpenAIModels sorting behavior', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.OPENAI_API_KEY = 'mockedApiKey';
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 'gpt-3.5-turbo-instruct-0914' },
|
||||
{ id: 'gpt-3.5-turbo-instruct' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-4-0314' },
|
||||
{ id: 'gpt-4-turbo-preview' },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ensures instruct models are listed last', async () => {
|
||||
const models = await getOpenAIModels({ user: 'user456' });
|
||||
|
||||
expect(models[models.length - 1]).toMatch(/instruct/);
|
||||
|
||||
const instructIndexes = models
|
||||
.map((model, index) => (model.includes('instruct') ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
const nonInstructIndexes = models
|
||||
.map((model, index) => (!model.includes('instruct') ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
expect(Math.max(...nonInstructIndexes)).toBeLessThan(Math.min(...instructIndexes));
|
||||
|
||||
const expectedOrder = [
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-4-0314',
|
||||
'gpt-4-turbo-preview',
|
||||
'gpt-3.5-turbo-instruct-0914',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
];
|
||||
expect(models).toEqual(expectedOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchModels with Ollama specific logic', () => {
|
||||
const mockOllamaData = {
|
||||
data: {
|
||||
models: [{ name: 'Ollama-Base' }, { name: 'Ollama-Advanced' }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedAxios.get.mockResolvedValue(mockOllamaData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch Ollama models when name starts with "ollama"', async () => {
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: {},
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass headers and user object to Ollama fetchModels', async () => {
|
||||
const customHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer custom-token',
|
||||
};
|
||||
const userObject = {
|
||||
id: 'user789',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
(resolveHeaders as jest.Mock).mockReturnValueOnce(customHeaders);
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'ollama',
|
||||
headers: customHeaders,
|
||||
userObject,
|
||||
});
|
||||
|
||||
expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']);
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: customHeaders,
|
||||
user: userObject,
|
||||
});
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags', {
|
||||
headers: customHeaders,
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully when fetching Ollama models fails and fallback to OpenAI-compatible fetch', async () => {
|
||||
mockedAxios.get.mockRejectedValueOnce(new Error('Ollama API error'));
|
||||
mockedAxios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'fallback-model-1' }, { id: 'fallback-model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.ollama.test.com',
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['fallback-model-1', 'fallback-model-2']);
|
||||
expect(logAxiosError).toHaveBeenCalledWith({
|
||||
message:
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.',
|
||||
error: expect.any(Error),
|
||||
});
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should return an empty array if no baseURL is provided', async () => {
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
name: 'OllamaAPI',
|
||||
});
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not fetch Ollama models if the name does not start with "ollama"', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 'model-1' }, { id: 'model-2' }],
|
||||
},
|
||||
});
|
||||
|
||||
const models = await fetchModels({
|
||||
user: 'user789',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com',
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(models).toEqual(['model-1', 'model-2']);
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('https://api.test.com/models', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchModels URL construction with trailing slashes', () => {
|
||||
beforeEach(() => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 'model-1' }, { id: 'model-2' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not create double slashes when baseURL has a trailing slash', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com/v1/',
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://api.test.com/v1/models',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle baseURL without trailing slash normally', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com/v1',
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://api.test.com/v1/models',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle baseURL with multiple trailing slashes', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com/v1///',
|
||||
name: 'TestAPI',
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://api.test.com/v1/models',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly append query params after stripping trailing slashes', async () => {
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'testApiKey',
|
||||
baseURL: 'https://api.test.com/v1/',
|
||||
name: 'TestAPI',
|
||||
userIdQuery: true,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
'https://api.test.com/v1/models?user=user123',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitAndTrim', () => {
|
||||
it('should split a string by commas and trim each value', () => {
|
||||
const input = ' model1, model2 , model3,model4 ';
|
||||
const expected = ['model1', 'model2', 'model3', 'model4'];
|
||||
expect(splitAndTrim(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty input', () => {
|
||||
expect(splitAndTrim('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for null input', () => {
|
||||
expect(splitAndTrim(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for undefined input', () => {
|
||||
expect(splitAndTrim(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter out empty values after trimming', () => {
|
||||
const input = 'model1,, ,model2,';
|
||||
const expected = ['model1', 'model2'];
|
||||
expect(splitAndTrim(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnthropicModels', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns default models when ANTHROPIC_MODELS is not set', async () => {
|
||||
delete process.env.ANTHROPIC_MODELS;
|
||||
const models = await getAnthropicModels();
|
||||
expect(models).toEqual(defaultModels[EModelEndpoint.anthropic]);
|
||||
});
|
||||
|
||||
it('returns models from ANTHROPIC_MODELS when set', async () => {
|
||||
process.env.ANTHROPIC_MODELS = 'claude-1, claude-2 ';
|
||||
const models = await getAnthropicModels();
|
||||
expect(models).toEqual(['claude-1', 'claude-2']);
|
||||
});
|
||||
|
||||
it('should use Anthropic-specific headers when fetching models', async () => {
|
||||
delete process.env.ANTHROPIC_MODELS;
|
||||
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 'claude-3' }, { id: 'claude-4' }],
|
||||
},
|
||||
});
|
||||
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'test-anthropic-key',
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
name: EModelEndpoint.anthropic,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'x-api-key': 'test-anthropic-key',
|
||||
'anthropic-version': expect.any(String),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers for Anthropic endpoint', async () => {
|
||||
const customHeaders = {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
};
|
||||
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 'claude-3' }],
|
||||
},
|
||||
});
|
||||
|
||||
await fetchModels({
|
||||
user: 'user123',
|
||||
apiKey: 'test-anthropic-key',
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
name: EModelEndpoint.anthropic,
|
||||
headers: customHeaders,
|
||||
});
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'x-api-key': 'test-anthropic-key',
|
||||
'anthropic-version': expect.any(String),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoogleModels', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns default models when GOOGLE_MODELS is not set', () => {
|
||||
delete process.env.GOOGLE_MODELS;
|
||||
const models = getGoogleModels();
|
||||
expect(models).toEqual(defaultModels[EModelEndpoint.google]);
|
||||
});
|
||||
|
||||
it('returns models from GOOGLE_MODELS when set', () => {
|
||||
process.env.GOOGLE_MODELS = 'gemini-pro, bard ';
|
||||
const models = getGoogleModels();
|
||||
expect(models).toEqual(['gemini-pro', 'bard']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBedrockModels', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns default models when BEDROCK_AWS_MODELS is not set', () => {
|
||||
delete process.env.BEDROCK_AWS_MODELS;
|
||||
const models = getBedrockModels();
|
||||
expect(models).toEqual(defaultModels[EModelEndpoint.bedrock]);
|
||||
});
|
||||
|
||||
it('returns models from BEDROCK_AWS_MODELS when set', () => {
|
||||
process.env.BEDROCK_AWS_MODELS = 'anthropic.claude-v2, ai21.j2-ultra ';
|
||||
const models = getBedrockModels();
|
||||
expect(models).toEqual(['anthropic.claude-v2', 'ai21.j2-ultra']);
|
||||
});
|
||||
});
|
||||
383
packages/api/src/endpoints/models.ts
Normal file
383
packages/api/src/endpoints/models.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import axios from 'axios';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { CacheKeys, KnownEndpoints, EModelEndpoint, defaultModels } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import {
|
||||
processModelData,
|
||||
extractBaseURL,
|
||||
isUserProvided,
|
||||
resolveHeaders,
|
||||
deriveBaseURL,
|
||||
logAxiosError,
|
||||
inputSchema,
|
||||
} from '~/utils';
|
||||
import { standardCache } from '~/cache';
|
||||
|
||||
export interface FetchModelsParams {
|
||||
/** User ID for API requests */
|
||||
user?: string;
|
||||
/** API key for authentication */
|
||||
apiKey: string;
|
||||
/** Base URL for the API */
|
||||
baseURL?: string;
|
||||
/** Endpoint name (defaults to 'openAI') */
|
||||
name?: string;
|
||||
/** Whether directEndpoint was configured */
|
||||
direct?: boolean;
|
||||
/** Whether to fetch from Azure */
|
||||
azure?: boolean;
|
||||
/** Whether to send user ID as query parameter */
|
||||
userIdQuery?: boolean;
|
||||
/** Whether to create token configuration from API response */
|
||||
createTokenConfig?: boolean;
|
||||
/** Cache key for token configuration (uses name if omitted) */
|
||||
tokenKey?: string;
|
||||
/** Optional headers for the request */
|
||||
headers?: Record<string, string> | null;
|
||||
/** Optional user object for header resolution */
|
||||
userObject?: Partial<IUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Ollama models from the specified base API path.
|
||||
* @param baseURL - The Ollama server URL
|
||||
* @param options - Optional configuration
|
||||
* @returns Promise resolving to array of model names
|
||||
*/
|
||||
async function fetchOllamaModels(
|
||||
baseURL: string,
|
||||
options: { headers?: Record<string, string> | null; user?: Partial<IUser> } = {},
|
||||
): Promise<string[]> {
|
||||
if (!baseURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ollamaEndpoint = deriveBaseURL(baseURL);
|
||||
|
||||
const resolvedHeaders = resolveHeaders({
|
||||
headers: options.headers ?? undefined,
|
||||
user: options.user,
|
||||
});
|
||||
|
||||
const response = await axios.get<{ models: Array<{ name: string }> }>(
|
||||
`${ollamaEndpoint}/api/tags`,
|
||||
{
|
||||
headers: resolvedHeaders,
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.models.map((tag) => tag.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a string by commas and trims each resulting value.
|
||||
* @param input - The input string to split.
|
||||
* @returns An array of trimmed values.
|
||||
*/
|
||||
export function splitAndTrim(input: string | null | undefined): string[] {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return input
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models from the specified base API path or Azure, based on the provided configuration.
|
||||
*
|
||||
* @param params - The parameters for fetching the models.
|
||||
* @returns A promise that resolves to an array of model identifiers.
|
||||
*/
|
||||
export async function fetchModels({
|
||||
user,
|
||||
apiKey,
|
||||
baseURL: _baseURL,
|
||||
name = EModelEndpoint.openAI,
|
||||
direct = false,
|
||||
azure = false,
|
||||
userIdQuery = false,
|
||||
createTokenConfig = true,
|
||||
tokenKey,
|
||||
headers,
|
||||
userObject,
|
||||
}: FetchModelsParams): Promise<string[]> {
|
||||
let models: string[] = [];
|
||||
const baseURL = direct ? extractBaseURL(_baseURL ?? '') : _baseURL;
|
||||
|
||||
if (!baseURL && !azure) {
|
||||
return models;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return models;
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
|
||||
try {
|
||||
return await fetchOllamaModels(baseURL ?? '', { headers, user: userObject });
|
||||
} catch (ollamaError) {
|
||||
const logMessage =
|
||||
'Failed to fetch models from Ollama API. Attempting to fetch via OpenAI-compatible endpoint.';
|
||||
logAxiosError({ message: logMessage, error: ollamaError as Error });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const options: {
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
httpsAgent?: HttpsProxyAgent;
|
||||
} = {
|
||||
headers: {
|
||||
...(headers ?? {}),
|
||||
},
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
if (name === EModelEndpoint.anthropic) {
|
||||
options.headers = {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
|
||||
};
|
||||
} else {
|
||||
options.headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
if (process.env.OPENAI_ORGANIZATION && baseURL?.includes('openai')) {
|
||||
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
|
||||
}
|
||||
|
||||
const url = new URL(`${(baseURL ?? '').replace(/\/+$/, '')}${azure ? '' : '/models'}`);
|
||||
if (user && userIdQuery) {
|
||||
url.searchParams.append('user', user);
|
||||
}
|
||||
const res = await axios.get(url.toString(), options);
|
||||
|
||||
const input = res.data;
|
||||
|
||||
const validationResult = inputSchema.safeParse(input);
|
||||
if (validationResult.success && createTokenConfig) {
|
||||
const endpointTokenConfig = processModelData(input);
|
||||
const cache = standardCache(CacheKeys.TOKEN_CONFIG);
|
||||
await cache.set(tokenKey ?? name, endpointTokenConfig);
|
||||
}
|
||||
models = input.data.map((item: { id: string }) => item.id);
|
||||
} catch (error) {
|
||||
const logMessage = `Failed to fetch models from ${azure ? 'Azure ' : ''}${name} API`;
|
||||
logAxiosError({ message: logMessage, error: error as Error });
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/** Options for fetching OpenAI models */
|
||||
export interface GetOpenAIModelsOptions {
|
||||
/** User ID for API requests */
|
||||
user?: string;
|
||||
/** Whether to fetch from Azure */
|
||||
azure?: boolean;
|
||||
/** Whether to fetch models for the Assistants endpoint */
|
||||
assistants?: boolean;
|
||||
/** OpenAI API key (if not using environment variable) */
|
||||
openAIApiKey?: string;
|
||||
/** Whether user provides their own API key */
|
||||
userProvidedOpenAI?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models from OpenAI or Azure based on the provided options.
|
||||
* @param opts - Options for fetching models
|
||||
* @param _models - Fallback models array
|
||||
* @returns Promise resolving to array of model IDs
|
||||
*/
|
||||
export async function fetchOpenAIModels(
|
||||
opts: GetOpenAIModelsOptions,
|
||||
_models: string[] = [],
|
||||
): Promise<string[]> {
|
||||
let models = _models.slice() ?? [];
|
||||
const apiKey = opts.openAIApiKey ?? process.env.OPENAI_API_KEY;
|
||||
const openaiBaseURL = 'https://api.openai.com/v1';
|
||||
let baseURL = openaiBaseURL;
|
||||
let reverseProxyUrl = process.env.OPENAI_REVERSE_PROXY;
|
||||
|
||||
if (opts.assistants && process.env.ASSISTANTS_BASE_URL) {
|
||||
reverseProxyUrl = process.env.ASSISTANTS_BASE_URL;
|
||||
} else if (opts.azure) {
|
||||
return models;
|
||||
}
|
||||
|
||||
if (reverseProxyUrl) {
|
||||
baseURL = extractBaseURL(reverseProxyUrl) ?? openaiBaseURL;
|
||||
}
|
||||
|
||||
const modelsCache = standardCache(CacheKeys.MODEL_QUERIES);
|
||||
|
||||
const cachedModels = await modelsCache.get(baseURL);
|
||||
if (cachedModels) {
|
||||
return cachedModels as string[];
|
||||
}
|
||||
|
||||
if (baseURL || opts.azure) {
|
||||
models = await fetchModels({
|
||||
apiKey: apiKey ?? '',
|
||||
baseURL,
|
||||
azure: opts.azure,
|
||||
user: opts.user,
|
||||
name: EModelEndpoint.openAI,
|
||||
});
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return _models;
|
||||
}
|
||||
|
||||
if (baseURL === openaiBaseURL) {
|
||||
const regex = /(text-davinci-003|gpt-|o\d+)/;
|
||||
const excludeRegex = /audio|realtime/;
|
||||
models = models.filter((model) => regex.test(model) && !excludeRegex.test(model));
|
||||
const instructModels = models.filter((model) => model.includes('instruct'));
|
||||
const otherModels = models.filter((model) => !model.includes('instruct'));
|
||||
models = otherModels.concat(instructModels);
|
||||
}
|
||||
|
||||
await modelsCache.set(baseURL, models);
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the default models for OpenAI or Azure.
|
||||
* @param opts - Options for getting models
|
||||
* @returns Promise resolving to array of model IDs
|
||||
*/
|
||||
export async function getOpenAIModels(opts: GetOpenAIModelsOptions = {}): Promise<string[]> {
|
||||
let models = defaultModels[EModelEndpoint.openAI];
|
||||
|
||||
if (opts.assistants) {
|
||||
models = defaultModels[EModelEndpoint.assistants];
|
||||
} else if (opts.azure) {
|
||||
models = defaultModels[EModelEndpoint.azureAssistants];
|
||||
}
|
||||
|
||||
let key: string;
|
||||
if (opts.assistants) {
|
||||
key = 'ASSISTANTS_MODELS';
|
||||
} else if (opts.azure) {
|
||||
key = 'AZURE_OPENAI_MODELS';
|
||||
} else {
|
||||
key = 'OPENAI_MODELS';
|
||||
}
|
||||
|
||||
if (process.env[key]) {
|
||||
return splitAndTrim(process.env[key]);
|
||||
}
|
||||
|
||||
if (opts.userProvidedOpenAI) {
|
||||
return models;
|
||||
}
|
||||
|
||||
return await fetchOpenAIModels(opts, models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models from the Anthropic API.
|
||||
* @param opts - Options for fetching models
|
||||
* @param _models - Fallback models array
|
||||
* @returns Promise resolving to array of model IDs
|
||||
*/
|
||||
export async function fetchAnthropicModels(
|
||||
opts: { user?: string } = {},
|
||||
_models: string[] = [],
|
||||
): Promise<string[]> {
|
||||
let models = _models.slice() ?? [];
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const anthropicBaseURL = 'https://api.anthropic.com/v1';
|
||||
let baseURL = anthropicBaseURL;
|
||||
const reverseProxyUrl = process.env.ANTHROPIC_REVERSE_PROXY;
|
||||
|
||||
if (reverseProxyUrl) {
|
||||
baseURL = extractBaseURL(reverseProxyUrl) ?? anthropicBaseURL;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return models;
|
||||
}
|
||||
|
||||
const modelsCache = standardCache(CacheKeys.MODEL_QUERIES);
|
||||
|
||||
const cachedModels = await modelsCache.get(baseURL);
|
||||
if (cachedModels) {
|
||||
return cachedModels as string[];
|
||||
}
|
||||
|
||||
if (baseURL) {
|
||||
models = await fetchModels({
|
||||
apiKey,
|
||||
baseURL,
|
||||
user: opts.user,
|
||||
name: EModelEndpoint.anthropic,
|
||||
tokenKey: EModelEndpoint.anthropic,
|
||||
});
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return _models;
|
||||
}
|
||||
|
||||
await modelsCache.set(baseURL, models);
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Anthropic models from environment or API.
|
||||
* @param opts - Options for fetching models
|
||||
* @returns Promise resolving to array of model IDs
|
||||
*/
|
||||
export async function getAnthropicModels(opts: { user?: string } = {}): Promise<string[]> {
|
||||
const models = defaultModels[EModelEndpoint.anthropic];
|
||||
if (process.env.ANTHROPIC_MODELS) {
|
||||
return splitAndTrim(process.env.ANTHROPIC_MODELS);
|
||||
}
|
||||
|
||||
if (isUserProvided(process.env.ANTHROPIC_API_KEY)) {
|
||||
return models;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchAnthropicModels(opts, models);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Anthropic models:', error);
|
||||
return models;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Google models from environment or defaults.
|
||||
* @returns Array of model IDs
|
||||
*/
|
||||
export function getGoogleModels(): string[] {
|
||||
let models = defaultModels[EModelEndpoint.google];
|
||||
if (process.env.GOOGLE_MODELS) {
|
||||
models = splitAndTrim(process.env.GOOGLE_MODELS);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Bedrock models from environment or defaults.
|
||||
* @returns Array of model IDs
|
||||
*/
|
||||
export function getBedrockModels(): string[] {
|
||||
let models = defaultModels[EModelEndpoint.bedrock];
|
||||
if (process.env.BEDROCK_AWS_MODELS) {
|
||||
models = splitAndTrim(process.env.BEDROCK_AWS_MODELS);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } from 'librechat-data-provider';
|
||||
import type {
|
||||
InitializeOpenAIOptionsParams,
|
||||
BaseInitializeParams,
|
||||
InitializeResultBase,
|
||||
OpenAIConfigOptions,
|
||||
LLMConfigResult,
|
||||
UserKeyValues,
|
||||
} from '~/types';
|
||||
import { getAzureCredentials } from '~/utils/azure';
|
||||
import { isUserProvided } from '~/utils/common';
|
||||
import { resolveHeaders } from '~/utils/env';
|
||||
import { getAzureCredentials, resolveHeaders, isUserProvided, checkUserKeyExpiry } from '~/utils';
|
||||
import { getOpenAIConfig } from './config';
|
||||
|
||||
/**
|
||||
|
|
@ -18,25 +16,18 @@ import { getOpenAIConfig } from './config';
|
|||
* @returns Promise resolving to OpenAI configuration options
|
||||
* @throws Error if API key is missing or user key has expired
|
||||
*/
|
||||
export const initializeOpenAI = async ({
|
||||
export async function initializeOpenAI({
|
||||
req,
|
||||
appConfig,
|
||||
overrideModel,
|
||||
endpointOption,
|
||||
overrideEndpoint,
|
||||
getUserKeyValues,
|
||||
checkUserKeyExpiry,
|
||||
}: InitializeOpenAIOptionsParams): Promise<LLMConfigResult> => {
|
||||
endpoint,
|
||||
model_parameters,
|
||||
db,
|
||||
}: BaseInitializeParams): Promise<InitializeResultBase> {
|
||||
const appConfig = req.config;
|
||||
const { PROXY, OPENAI_API_KEY, AZURE_API_KEY, OPENAI_REVERSE_PROXY, AZURE_OPENAI_BASEURL } =
|
||||
process.env;
|
||||
|
||||
const { key: expiresAt } = req.body;
|
||||
const modelName = overrideModel ?? req.body.model;
|
||||
const endpoint = overrideEndpoint ?? req.body.endpoint;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('Endpoint is required');
|
||||
}
|
||||
const modelName = model_parameters?.model as string | undefined;
|
||||
|
||||
const credentials = {
|
||||
[EModelEndpoint.openAI]: OPENAI_API_KEY,
|
||||
|
|
@ -54,7 +45,7 @@ export const initializeOpenAI = async ({
|
|||
let userValues: UserKeyValues | null = null;
|
||||
if (expiresAt && (userProvidesKey || userProvidesURL)) {
|
||||
checkUserKeyExpiry(expiresAt, endpoint);
|
||||
userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint });
|
||||
userValues = await db.getUserKeyValues({ userId: req.user?.id ?? '', name: endpoint });
|
||||
}
|
||||
|
||||
let apiKey = userProvidesKey
|
||||
|
|
@ -71,7 +62,8 @@ export const initializeOpenAI = async ({
|
|||
};
|
||||
|
||||
const isAzureOpenAI = endpoint === EModelEndpoint.azureOpenAI;
|
||||
const azureConfig = isAzureOpenAI && appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
|
||||
const azureConfig = isAzureOpenAI && appConfig?.endpoints?.[EModelEndpoint.azureOpenAI];
|
||||
let isServerless = false;
|
||||
|
||||
if (isAzureOpenAI && azureConfig) {
|
||||
const { modelGroupMap, groupMap } = azureConfig;
|
||||
|
|
@ -85,6 +77,7 @@ export const initializeOpenAI = async ({
|
|||
modelGroupMap,
|
||||
groupMap,
|
||||
});
|
||||
isServerless = serverless === true;
|
||||
|
||||
clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl;
|
||||
clientOptions.headers = resolveHeaders({
|
||||
|
|
@ -99,9 +92,9 @@ export const initializeOpenAI = async ({
|
|||
}
|
||||
|
||||
apiKey = azureOptions.azureOpenAIApiKey;
|
||||
clientOptions.azure = !serverless ? azureOptions : undefined;
|
||||
clientOptions.azure = !isServerless ? azureOptions : undefined;
|
||||
|
||||
if (serverless === true) {
|
||||
if (isServerless) {
|
||||
clientOptions.defaultQuery = azureOptions.azureOpenAIApiVersion
|
||||
? { 'api-version': azureOptions.azureOpenAIApiVersion }
|
||||
: undefined;
|
||||
|
|
@ -130,9 +123,9 @@ export const initializeOpenAI = async ({
|
|||
}
|
||||
|
||||
const modelOptions = {
|
||||
...endpointOption.model_parameters,
|
||||
...(model_parameters ?? {}),
|
||||
model: modelName,
|
||||
user: req.user.id,
|
||||
user: req.user?.id,
|
||||
};
|
||||
|
||||
const finalClientOptions: OpenAIConfigOptions = {
|
||||
|
|
@ -142,8 +135,13 @@ export const initializeOpenAI = async ({
|
|||
|
||||
const options = getOpenAIConfig(apiKey, finalClientOptions, endpoint);
|
||||
|
||||
const openAIConfig = appConfig.endpoints?.[EModelEndpoint.openAI];
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
/** Set useLegacyContent for Azure serverless deployments */
|
||||
if (isServerless) {
|
||||
(options as InitializeResultBase).useLegacyContent = true;
|
||||
}
|
||||
|
||||
const openAIConfig = appConfig?.endpoints?.[EModelEndpoint.openAI];
|
||||
const allConfig = appConfig?.endpoints?.all;
|
||||
const azureRate = modelName?.includes('gpt-4') ? 30 : 17;
|
||||
|
||||
let streamRate: number | undefined;
|
||||
|
|
@ -163,4 +161,4 @@ export const initializeOpenAI = async ({
|
|||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { MCPTokenStorage } from '~/mcp/oauth/tokens';
|
||||
import { decryptV2 } from '~/crypto';
|
||||
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
||||
import { Types } from 'mongoose';
|
||||
import { decryptV2 } from '@librechat/data-schemas';
|
||||
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
||||
import { MCPTokenStorage } from '~/mcp/oauth/tokens';
|
||||
|
||||
jest.mock('~/crypto', () => ({
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
decryptV2: jest.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, encryptV2, decryptV2 } from '@librechat/data-schemas';
|
||||
import type { OAuthTokens, OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { TokenMethods, IToken } from '@librechat/data-schemas';
|
||||
import type { MCPOAuthTokens, ExtendedOAuthTokens, OAuthMetadata } from './types';
|
||||
import { encryptV2, decryptV2 } from '~/crypto';
|
||||
import { isSystemUserId } from '~/mcp/enum';
|
||||
|
||||
interface StoreTokensParams {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import axios from 'axios';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, encryptV2, decryptV2 } from '@librechat/data-schemas';
|
||||
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
|
||||
import type { TokenMethods } from '@librechat/data-schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import { encryptV2, decryptV2 } from '~/crypto';
|
||||
import { logAxiosError } from '~/utils';
|
||||
|
||||
export function createHandleOAuthToken({
|
||||
|
|
|
|||
711
packages/api/src/prompts/artifacts/components.ts
Normal file
711
packages/api/src/prompts/artifacts/components.ts
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
export interface ShadcnComponent {
|
||||
componentName: string;
|
||||
importDocs: string;
|
||||
usageDocs: string;
|
||||
}
|
||||
|
||||
/** Essential Components */
|
||||
const essentialComponents: Record<string, ShadcnComponent> = {
|
||||
avatar: {
|
||||
componentName: 'Avatar',
|
||||
importDocs: 'import { Avatar, AvatarFallback, AvatarImage } from "/components/ui/avatar"',
|
||||
usageDocs: `
|
||||
<Avatar>
|
||||
<AvatarImage src="https://github.com/shadcn.png" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>`,
|
||||
},
|
||||
button: {
|
||||
componentName: 'Button',
|
||||
importDocs: 'import { Button } from "/components/ui/button"',
|
||||
usageDocs: `
|
||||
<Button variant="outline">Button</Button>`,
|
||||
},
|
||||
card: {
|
||||
componentName: 'Card',
|
||||
importDocs: `
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "/components/ui/card"`,
|
||||
usageDocs: `
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Card Content</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p>Card Footer</p>
|
||||
</CardFooter>
|
||||
</Card>`,
|
||||
},
|
||||
checkbox: {
|
||||
componentName: 'Checkbox',
|
||||
importDocs: 'import { Checkbox } from "/components/ui/checkbox"',
|
||||
usageDocs: '<Checkbox />',
|
||||
},
|
||||
input: {
|
||||
componentName: 'Input',
|
||||
importDocs: 'import { Input } from "/components/ui/input"',
|
||||
usageDocs: '<Input />',
|
||||
},
|
||||
label: {
|
||||
componentName: 'Label',
|
||||
importDocs: 'import { Label } from "/components/ui/label"',
|
||||
usageDocs: '<Label htmlFor="email">Your email address</Label>',
|
||||
},
|
||||
radioGroup: {
|
||||
componentName: 'RadioGroup',
|
||||
importDocs: `
|
||||
import { Label } from "/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "/components/ui/radio-group"`,
|
||||
usageDocs: `
|
||||
<RadioGroup defaultValue="option-one">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="option-one" id="option-one" />
|
||||
<Label htmlFor="option-one">Option One</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="option-two" id="option-two" />
|
||||
<Label htmlFor="option-two">Option Two</Label>
|
||||
</div>
|
||||
</RadioGroup>`,
|
||||
},
|
||||
select: {
|
||||
componentName: 'Select',
|
||||
importDocs: `
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "/components/ui/select"`,
|
||||
usageDocs: `
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>`,
|
||||
},
|
||||
textarea: {
|
||||
componentName: 'Textarea',
|
||||
importDocs: 'import { Textarea } from "/components/ui/textarea"',
|
||||
usageDocs: '<Textarea />',
|
||||
},
|
||||
};
|
||||
|
||||
/** Extra Components */
|
||||
const extraComponents: Record<string, ShadcnComponent> = {
|
||||
accordion: {
|
||||
componentName: 'Accordion',
|
||||
importDocs: `
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "/components/ui/accordion"`,
|
||||
usageDocs: `
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>`,
|
||||
},
|
||||
alertDialog: {
|
||||
componentName: 'AlertDialog',
|
||||
importDocs: `
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "/components/ui/alert-dialog"`,
|
||||
usageDocs: `
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>`,
|
||||
},
|
||||
alert: {
|
||||
componentName: 'Alert',
|
||||
importDocs: `
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "/components/ui/alert"`,
|
||||
usageDocs: `
|
||||
<Alert>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can add components to your app using the cli.
|
||||
</AlertDescription>
|
||||
</Alert>`,
|
||||
},
|
||||
aspectRatio: {
|
||||
componentName: 'AspectRatio',
|
||||
importDocs: 'import { AspectRatio } from "/components/ui/aspect-ratio"',
|
||||
usageDocs: `
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Image src="..." alt="Image" className="rounded-md object-cover" />
|
||||
</AspectRatio>`,
|
||||
},
|
||||
badge: {
|
||||
componentName: 'Badge',
|
||||
importDocs: 'import { Badge } from "/components/ui/badge"',
|
||||
usageDocs: '<Badge>Badge</Badge>',
|
||||
},
|
||||
calendar: {
|
||||
componentName: 'Calendar',
|
||||
importDocs: 'import { Calendar } from "/components/ui/calendar"',
|
||||
usageDocs: '<Calendar />',
|
||||
},
|
||||
carousel: {
|
||||
componentName: 'Carousel',
|
||||
importDocs: `
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "/components/ui/carousel"`,
|
||||
usageDocs: `
|
||||
<Carousel>
|
||||
<CarouselContent>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>`,
|
||||
},
|
||||
collapsible: {
|
||||
componentName: 'Collapsible',
|
||||
importDocs: `
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "/components/ui/collapsible"`,
|
||||
usageDocs: `
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
Yes. Free to use for personal and commercial projects. No attribution required.
|
||||
</CollapsibleContent>
|
||||
</Collapsible>`,
|
||||
},
|
||||
dialog: {
|
||||
componentName: 'Dialog',
|
||||
importDocs: `
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "/components/ui/dialog"`,
|
||||
usageDocs: `
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>`,
|
||||
},
|
||||
dropdownMenu: {
|
||||
componentName: 'DropdownMenu',
|
||||
importDocs: `
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "/components/ui/dropdown-menu"`,
|
||||
usageDocs: `
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuItem>Subscription</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>`,
|
||||
},
|
||||
menubar: {
|
||||
componentName: 'Menubar',
|
||||
importDocs: `
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarTrigger,
|
||||
} from "/components/ui/menubar"`,
|
||||
usageDocs: `
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
New Tab <MenubarShortcut>⌘T</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>New Window</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Share</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Print</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>`,
|
||||
},
|
||||
navigationMenu: {
|
||||
componentName: 'NavigationMenu',
|
||||
importDocs: `
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "/components/ui/navigation-menu"`,
|
||||
usageDocs: `
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Item One</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<NavigationMenuLink>Link</NavigationMenuLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>`,
|
||||
},
|
||||
popover: {
|
||||
componentName: 'Popover',
|
||||
importDocs: `
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "/components/ui/popover"`,
|
||||
usageDocs: `
|
||||
<Popover>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Place content for the popover here.</PopoverContent>
|
||||
</Popover>`,
|
||||
},
|
||||
progress: {
|
||||
componentName: 'Progress',
|
||||
importDocs: 'import { Progress } from "/components/ui/progress"',
|
||||
usageDocs: '<Progress value={33} />',
|
||||
},
|
||||
separator: {
|
||||
componentName: 'Separator',
|
||||
importDocs: 'import { Separator } from "/components/ui/separator"',
|
||||
usageDocs: '<Separator />',
|
||||
},
|
||||
sheet: {
|
||||
componentName: 'Sheet',
|
||||
importDocs: `
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "/components/ui/sheet"`,
|
||||
usageDocs: `
|
||||
<Sheet>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Are you sure absolutely sure?</SheetTitle>
|
||||
<SheetDescription>
|
||||
This action cannot be undone.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>`,
|
||||
},
|
||||
skeleton: {
|
||||
componentName: 'Skeleton',
|
||||
importDocs: 'import { Skeleton } from "/components/ui/skeleton"',
|
||||
usageDocs: '<Skeleton className="w-[100px] h-[20px] rounded-full" />',
|
||||
},
|
||||
slider: {
|
||||
componentName: 'Slider',
|
||||
importDocs: 'import { Slider } from "/components/ui/slider"',
|
||||
usageDocs: '<Slider defaultValue={[33]} max={100} step={1} />',
|
||||
},
|
||||
switch: {
|
||||
componentName: 'Switch',
|
||||
importDocs: 'import { Switch } from "/components/ui/switch"',
|
||||
usageDocs: '<Switch />',
|
||||
},
|
||||
table: {
|
||||
componentName: 'Table',
|
||||
importDocs: `
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "/components/ui/table"`,
|
||||
usageDocs: `
|
||||
<Table>
|
||||
<TableCaption>A list of your recent invoices.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Invoice</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">INV001</TableCell>
|
||||
<TableCell>Paid</TableCell>
|
||||
<TableCell>Credit Card</TableCell>
|
||||
<TableCell className="text-right">$250.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>`,
|
||||
},
|
||||
tabs: {
|
||||
componentName: 'Tabs',
|
||||
importDocs: `
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "/components/ui/tabs"`,
|
||||
usageDocs: `
|
||||
<Tabs defaultValue="account" className="w-[400px]">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">Make changes to your account here.</TabsContent>
|
||||
<TabsContent value="password">Change your password here.</TabsContent>
|
||||
</Tabs>`,
|
||||
},
|
||||
toast: {
|
||||
componentName: 'Toast',
|
||||
importDocs: `
|
||||
import { useToast } from "/components/ui/use-toast"
|
||||
import { Button } from "/components/ui/button"`,
|
||||
usageDocs: `
|
||||
export function ToastDemo() {
|
||||
const { toast } = useToast()
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: "Scheduled: Catch up",
|
||||
description: "Friday, February 10, 2023 at 5:57 PM",
|
||||
})
|
||||
}}
|
||||
>
|
||||
Show Toast
|
||||
</Button>
|
||||
)
|
||||
}`,
|
||||
},
|
||||
toggle: {
|
||||
componentName: 'Toggle',
|
||||
importDocs: 'import { Toggle } from "/components/ui/toggle"',
|
||||
usageDocs: '<Toggle>Toggle</Toggle>',
|
||||
},
|
||||
tooltip: {
|
||||
componentName: 'Tooltip',
|
||||
importDocs: `
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "/components/ui/tooltip"`,
|
||||
usageDocs: `
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>Hover</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add to library</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>`,
|
||||
},
|
||||
};
|
||||
|
||||
/** Extra Components continued */
|
||||
const moreExtraComponents: Record<string, ShadcnComponent> = {
|
||||
calendar: {
|
||||
componentName: 'Calendar',
|
||||
importDocs: 'import { Calendar } from "/components/ui/calendar"',
|
||||
usageDocs: '<Calendar />',
|
||||
},
|
||||
carousel: {
|
||||
componentName: 'Carousel',
|
||||
importDocs: `
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "/components/ui/carousel"`,
|
||||
usageDocs: `
|
||||
<Carousel>
|
||||
<CarouselContent>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
<CarouselItem>...</CarouselItem>
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>`,
|
||||
},
|
||||
collapsible: {
|
||||
componentName: 'Collapsible',
|
||||
importDocs: `
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "/components/ui/collapsible"`,
|
||||
usageDocs: `
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
Yes. Free to use for personal and commercial projects. No attribution required.
|
||||
</CollapsibleContent>
|
||||
</Collapsible>`,
|
||||
},
|
||||
dialog: {
|
||||
componentName: 'Dialog',
|
||||
importDocs: `
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "/components/ui/dialog"`,
|
||||
usageDocs: `
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>`,
|
||||
},
|
||||
dropdownMenu: {
|
||||
componentName: 'DropdownMenu',
|
||||
importDocs: `
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "/components/ui/dropdown-menu"`,
|
||||
usageDocs: `
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuItem>Subscription</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>`,
|
||||
},
|
||||
menubar: {
|
||||
componentName: 'Menubar',
|
||||
importDocs: `
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarTrigger,
|
||||
} from "/components/ui/menubar"`,
|
||||
usageDocs: `
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
New Tab <MenubarShortcut>⌘T</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>New Window</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Share</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Print</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>`,
|
||||
},
|
||||
navigationMenu: {
|
||||
componentName: 'NavigationMenu',
|
||||
importDocs: `
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "/components/ui/navigation-menu"`,
|
||||
usageDocs: `
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Item One</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<NavigationMenuLink>Link</NavigationMenuLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>`,
|
||||
},
|
||||
popover: {
|
||||
componentName: 'Popover',
|
||||
importDocs: `
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "/components/ui/popover"`,
|
||||
usageDocs: `
|
||||
<Popover>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Place content for the popover here.</PopoverContent>
|
||||
</Popover>`,
|
||||
},
|
||||
progress: {
|
||||
componentName: 'Progress',
|
||||
importDocs: 'import { Progress } from "/components/ui/progress"',
|
||||
usageDocs: '<Progress value={33} />',
|
||||
},
|
||||
separator: {
|
||||
componentName: 'Separator',
|
||||
importDocs: 'import { Separator } from "/components/ui/separator"',
|
||||
usageDocs: '<Separator />',
|
||||
},
|
||||
sheet: {
|
||||
componentName: 'Sheet',
|
||||
importDocs: `
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "/components/ui/sheet"`,
|
||||
usageDocs: `
|
||||
<Sheet>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Are you sure absolutely sure?</SheetTitle>
|
||||
<SheetDescription>
|
||||
This action cannot be undone.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>`,
|
||||
},
|
||||
skeleton: {
|
||||
componentName: 'Skeleton',
|
||||
importDocs: 'import { Skeleton } from "/components/ui/skeleton"',
|
||||
usageDocs: '<Skeleton className="w-[100px] h-[20px] rounded-full" />',
|
||||
},
|
||||
slider: {
|
||||
componentName: 'Slider',
|
||||
importDocs: 'import { Slider } from "/components/ui/slider"',
|
||||
usageDocs: '<Slider defaultValue={[33]} max={100} step={1} />',
|
||||
},
|
||||
switch: {
|
||||
componentName: 'Switch',
|
||||
importDocs: 'import { Switch } from "/components/ui/switch"',
|
||||
usageDocs: '<Switch />',
|
||||
},
|
||||
};
|
||||
|
||||
export const components: Record<string, ShadcnComponent> = {
|
||||
...essentialComponents,
|
||||
...extraComponents,
|
||||
...moreExtraComponents,
|
||||
};
|
||||
49
packages/api/src/prompts/artifacts/generate.ts
Normal file
49
packages/api/src/prompts/artifacts/generate.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import dedent from 'dedent';
|
||||
import type { ShadcnComponent } from './components';
|
||||
|
||||
/**
|
||||
* Generate system prompt for AI-assisted React component creation
|
||||
* @param options - Configuration options
|
||||
* @param options.components - Documentation for shadcn components
|
||||
* @param options.useXML - Whether to use XML-style formatting for component instructions
|
||||
* @returns The generated system prompt
|
||||
*/
|
||||
export function generateShadcnPrompt(options: {
|
||||
components: Record<string, ShadcnComponent>;
|
||||
useXML?: boolean;
|
||||
}): string {
|
||||
const { components, useXML = false } = options;
|
||||
|
||||
const systemPrompt = dedent`
|
||||
## Additional Artifact Instructions for React Components: "application/vnd.react"
|
||||
|
||||
There are some prestyled components (primitives) available for use. Please use your best judgement to use any of these components if the app calls for one.
|
||||
|
||||
Here are the components that are available, along with how to import them, and how to use them:
|
||||
|
||||
${Object.values(components)
|
||||
.map((component) => {
|
||||
if (useXML) {
|
||||
return dedent`
|
||||
<component>
|
||||
<name>${component.componentName}</name>
|
||||
<import-instructions>${component.importDocs}</import-instructions>
|
||||
<usage-instructions>${component.usageDocs}</usage-instructions>
|
||||
</component>
|
||||
`;
|
||||
}
|
||||
return dedent`
|
||||
# ${component.componentName}
|
||||
|
||||
## Import Instructions
|
||||
${component.importDocs}
|
||||
|
||||
## Usage Instructions
|
||||
${component.usageDocs}
|
||||
`;
|
||||
})
|
||||
.join('\n\n')}
|
||||
`;
|
||||
|
||||
return systemPrompt;
|
||||
}
|
||||
426
packages/api/src/prompts/artifacts/index.ts
Normal file
426
packages/api/src/prompts/artifacts/index.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import dedent from 'dedent';
|
||||
import { EModelEndpoint, ArtifactModes } from 'librechat-data-provider';
|
||||
import { generateShadcnPrompt } from './generate';
|
||||
import { components } from './components';
|
||||
|
||||
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
|
||||
|
||||
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||
|
||||
# Good artifacts are...
|
||||
- Substantial content (>15 lines)
|
||||
- Content that the user is likely to modify, iterate on, or take ownership of
|
||||
- Self-contained, complex content that can be understood on its own, without context from the conversation
|
||||
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
|
||||
- Content likely to be referenced or reused multiple times
|
||||
|
||||
# Don't use artifacts for...
|
||||
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
|
||||
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
|
||||
- Suggestions, commentary, or feedback on existing artifacts
|
||||
- Conversational or explanatory content that doesn't represent a standalone piece of work
|
||||
- Content that is dependent on the current conversational context to be useful
|
||||
- Content that is unlikely to be modified or iterated upon by the user
|
||||
- Request from users that appears to be a one-off question
|
||||
|
||||
# Usage notes
|
||||
- One artifact per message unless specifically requested
|
||||
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
|
||||
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
|
||||
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
|
||||
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
|
||||
- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
|
||||
- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
|
||||
|
||||
<artifact_instructions>
|
||||
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
|
||||
|
||||
1. Create the artifact using the following format:
|
||||
|
||||
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
|
||||
\`\`\`
|
||||
Your artifact content here
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
|
||||
3. Include a \`title\` attribute to provide a brief title or description of the content.
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
- Markdown: "text/markdown" or "text/md"
|
||||
- The user interface will render Markdown content placed within the artifact tags.
|
||||
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
- React Components: "application/vnd.react"
|
||||
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
|
||||
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
|
||||
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
|
||||
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
|
||||
- The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
|
||||
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
|
||||
- The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
|
||||
- The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
|
||||
- The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
|
||||
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
|
||||
7. Always use triple backticks (\`\`\`) to enclose the content within the artifact, regardless of the content type.
|
||||
</artifact_instructions>
|
||||
|
||||
Here are some examples of correct usage of artifacts:
|
||||
|
||||
<examples>
|
||||
<example_docstring>
|
||||
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
|
||||
</example_docstring>
|
||||
|
||||
<example>
|
||||
<user_query>Can you create a simple flow chart showing the process of making tea using Mermaid?</user_query>
|
||||
|
||||
<assistant_response>
|
||||
Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
|
||||
|
||||
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A[Start] --> B{Water boiled?}
|
||||
B -->|Yes| C[Add tea leaves to cup]
|
||||
B -->|No| D[Boil water]
|
||||
D --> B
|
||||
C --> E[Pour boiling water into cup]
|
||||
E --> F[Steep tea for desired time]
|
||||
F --> G[Remove tea leaves]
|
||||
G --> H[Add milk or sugar, if desired]
|
||||
H --> I[Enjoy your tea!]
|
||||
I --> J[End]
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
|
||||
|
||||
1. Start
|
||||
2. Check if water is boiled
|
||||
3. If not boiled, boil the water
|
||||
4. Once water is boiled, add tea leaves to the cup
|
||||
5. Pour boiling water into the cup
|
||||
6. Steep the tea for the desired time
|
||||
7. Remove the tea leaves
|
||||
8. Optionally add milk or sugar
|
||||
9. Enjoy your tea!
|
||||
10. End
|
||||
|
||||
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>Create a simple React counter component</user_query>
|
||||
<assistant_response>
|
||||
Here's a simple React counter component:
|
||||
|
||||
:::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
|
||||
\`\`\`
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="mb-2">Count: {count}</p>
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={() => setCount(count + 1)}>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This component creates a simple counter with an increment button.
|
||||
</assistant_response>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<user_query>Create a basic HTML structure for a blog post</user_query>
|
||||
<assistant_response>
|
||||
Here's a basic HTML structure for a blog post:
|
||||
|
||||
:::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
|
||||
\`\`\`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Blog Post</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
h1 { color: #333; }
|
||||
p { margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My First Blog Post</h1>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<p>This is the content of my blog post. It's short and sweet!</p>
|
||||
</article>
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2023 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This HTML structure provides a simple layout for a blog post.
|
||||
</assistant_response>
|
||||
</example>
|
||||
</examples>`;
|
||||
|
||||
const artifactsOpenAIPrompt = dedent`The assistant can create and reference artifacts during conversations.
|
||||
|
||||
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||
|
||||
# Good artifacts are...
|
||||
- Substantial content (>15 lines)
|
||||
- Content that the user is likely to modify, iterate on, or take ownership of
|
||||
- Self-contained, complex content that can be understood on its own, without context from the conversation
|
||||
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
|
||||
- Content likely to be referenced or reused multiple times
|
||||
|
||||
# Don't use artifacts for...
|
||||
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
|
||||
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
|
||||
- Suggestions, commentary, or feedback on existing artifacts
|
||||
- Conversational or explanatory content that doesn't represent a standalone piece of work
|
||||
- Content that is dependent on the current conversational context to be useful
|
||||
- Content that is unlikely to be modified or iterated upon by the user
|
||||
- Request from users that appears to be a one-off question
|
||||
|
||||
# Usage notes
|
||||
- One artifact per message unless specifically requested
|
||||
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
|
||||
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
|
||||
- If asked to generate an image, the assistant can offer an SVG instead. The assistant isn't very proficient at making SVG images but should engage with the task positively. Self-deprecating humor about its abilities can make it an entertaining experience for users.
|
||||
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
|
||||
- Always provide complete, specific, and fully functional content for artifacts without any snippets, placeholders, ellipses, or 'remains the same' comments.
|
||||
- If an artifact is not necessary or requested, the assistant should not mention artifacts at all, and respond to the user accordingly.
|
||||
|
||||
## Artifact Instructions
|
||||
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
|
||||
|
||||
1. Create the artifact using the following remark-directive markdown format:
|
||||
|
||||
:::artifact{identifier="unique-identifier" type="mime-type" title="Artifact Title"}
|
||||
\`\`\`
|
||||
Your artifact content here
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
a. Example of correct format:
|
||||
|
||||
:::artifact{identifier="example-artifact" type="text/plain" title="Example Artifact"}
|
||||
\`\`\`
|
||||
This is the content of the artifact.
|
||||
It can span multiple lines.
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
b. Common mistakes to avoid:
|
||||
- Don't split the opening ::: line
|
||||
- Don't add extra backticks outside the artifact structure
|
||||
- Don't omit the closing :::
|
||||
|
||||
2. Assign an identifier to the \`identifier\` attribute. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
|
||||
3. Include a \`title\` attribute to provide a brief title or description of the content.
|
||||
4. Add a \`type\` attribute to specify the type of content the artifact represents. Assign one of the following values to the \`type\` attribute:
|
||||
- HTML: "text/html"
|
||||
- The user interface can render single file HTML pages placed within the artifact tags. HTML, JS, and CSS should be in a single file when using the \`text/html\` type.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- The only place external scripts can be imported from is https://cdnjs.cloudflare.com
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
- Markdown: "text/markdown" or "text/md"
|
||||
- The user interface will render Markdown content placed within the artifact tags.
|
||||
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||
- React Components: "application/vnd.react"
|
||||
- Use this for displaying either: React elements, e.g. \`<strong>Hello World!</strong>\`, React pure functional components, e.g. \`() => <strong>Hello World!</strong>\`, React functional components with Hooks, or React component classes
|
||||
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
|
||||
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES (e.g. \`h-[600px]\`).
|
||||
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. \`import { useState } from "react"\`
|
||||
- The lucide-react@0.394.0 library is available to be imported. e.g. \`import { Camera } from "lucide-react"\` & \`<Camera color="red" size={48} />\`
|
||||
- The recharts charting library is available to be imported, e.g. \`import { LineChart, XAxis, ... } from "recharts"\` & \`<LineChart ...><XAxis dataKey="name"> ...\`
|
||||
- The three.js library is available to be imported, e.g. \`import * as THREE from "three";\`
|
||||
- The date-fns library is available to be imported, e.g. \`import { compareAsc, format } from "date-fns";\`
|
||||
- The react-day-picker library is available to be imported, e.g. \`import { DayPicker } from "react-day-picker";\`
|
||||
- The assistant can use prebuilt components from the \`shadcn/ui\` library after it is imported: \`import { Alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '/components/ui/alert';\`. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- Components MUST be imported from \`/components/ui/name\` and NOT from \`/components/name\` or \`@/components/ui/name\`.
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so \`<img src="/api/placeholder/400/320" alt="placeholder" />\`
|
||||
- When iterating on code, ensure that the code is complete and functional without any snippets, placeholders, or ellipses.
|
||||
- If you are unable to follow the above requirements for any reason, don't use artifacts and use regular code blocks instead, which will not attempt to render the component.
|
||||
5. Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
6. If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
|
||||
7. NEVER use triple backticks to enclose the artifact, ONLY the content within the artifact.
|
||||
|
||||
Here are some examples of correct usage of artifacts:
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1
|
||||
|
||||
This example demonstrates how to create a Mermaid artifact for a simple flow chart.
|
||||
|
||||
User: Can you create a simple flow chart showing the process of making tea using Mermaid?
|
||||
|
||||
Assistant: Sure! Here's a simple flow chart depicting the process of making tea using Mermaid syntax:
|
||||
|
||||
:::artifact{identifier="tea-making-flowchart" type="application/vnd.mermaid" title="Flow chart: Making Tea"}
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
A[Start] --> B{Water boiled?}
|
||||
B -->|Yes| C[Add tea leaves to cup]
|
||||
B -->|No| D[Boil water]
|
||||
D --> B
|
||||
C --> E[Pour boiling water into cup]
|
||||
E --> F[Steep tea for desired time]
|
||||
F --> G[Remove tea leaves]
|
||||
G --> H[Add milk or sugar, if desired]
|
||||
H --> I[Enjoy your tea!]
|
||||
I --> J[End]
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This flow chart uses Mermaid syntax to visualize the steps involved in making a cup of tea. Here's a brief explanation of the process:
|
||||
|
||||
1. Start
|
||||
2. Check if water is boiled
|
||||
3. If not boiled, boil the water
|
||||
4. Once water is boiled, add tea leaves to the cup
|
||||
5. Pour boiling water into the cup
|
||||
6. Steep the tea for the desired time
|
||||
7. Remove the tea leaves
|
||||
8. Optionally add milk or sugar
|
||||
9. Enjoy your tea!
|
||||
10. End
|
||||
|
||||
This chart provides a clear visual representation of the tea-making process. You can easily modify or expand this chart if you want to add more details or steps to the process. Let me know if you'd like any changes or have any questions!
|
||||
|
||||
---
|
||||
|
||||
### Example 2
|
||||
|
||||
User: Create a simple React counter component
|
||||
|
||||
Assistant: Here's a simple React counter component:
|
||||
|
||||
:::artifact{identifier="react-counter" type="application/vnd.react" title="React Counter"}
|
||||
\`\`\`
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="mb-2">Count: {count}</p>
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={() => setCount(count + 1)}>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This component creates a simple counter with an increment button.
|
||||
|
||||
---
|
||||
|
||||
### Example 3
|
||||
User: Create a basic HTML structure for a blog post
|
||||
Assistant: Here's a basic HTML structure for a blog post:
|
||||
|
||||
:::artifact{identifier="blog-post-html" type="text/html" title="Blog Post HTML"}
|
||||
\`\`\`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Blog Post</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
h1 { color: #333; }
|
||||
p { margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>My First Blog Post</h1>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<p>This is the content of my blog post. It's short and sweet!</p>
|
||||
</article>
|
||||
</main>
|
||||
<footer>
|
||||
<p>© 2023 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
This HTML structure provides a simple layout for a blog post.
|
||||
|
||||
---`;
|
||||
|
||||
/**
|
||||
* Generates an artifacts prompt based on the endpoint and artifact mode
|
||||
* @param params - Configuration parameters
|
||||
* @param params.endpoint - The current endpoint
|
||||
* @param params.artifacts - The current artifact mode
|
||||
* @returns The artifacts prompt, or null if mode is CUSTOM
|
||||
*/
|
||||
export function generateArtifactsPrompt(params: {
|
||||
endpoint: EModelEndpoint | string;
|
||||
artifacts: ArtifactModes;
|
||||
}): string | null {
|
||||
const { endpoint, artifacts } = params;
|
||||
|
||||
if (artifacts === ArtifactModes.CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prompt = artifactsPrompt;
|
||||
if (endpoint !== EModelEndpoint.anthropic) {
|
||||
prompt = artifactsOpenAIPrompt;
|
||||
}
|
||||
|
||||
if (artifacts === ArtifactModes.SHADCNUI) {
|
||||
prompt += generateShadcnPrompt({ components, useXML: endpoint === EModelEndpoint.anthropic });
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './artifacts';
|
||||
export * from './format';
|
||||
export * from './migration';
|
||||
export * from './schemas';
|
||||
|
|
|
|||
37
packages/api/src/types/bedrock.ts
Normal file
37
packages/api/src/types/bedrock.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import type { BedrockConverseInput } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* AWS credentials for Bedrock
|
||||
* Extends AWS AwsCredentialIdentity to ensure compatibility
|
||||
*/
|
||||
export type BedrockCredentials = Partial<AwsCredentialIdentity>;
|
||||
|
||||
/**
|
||||
* Configuration options for Bedrock LLM
|
||||
*/
|
||||
export interface BedrockConfigOptions {
|
||||
modelOptions?: Partial<BedrockConverseInput>;
|
||||
/** AWS region for Bedrock */
|
||||
region?: string;
|
||||
/** Optional pre-configured Bedrock client (used with proxy) */
|
||||
client?: BedrockRuntimeClient;
|
||||
/** AWS credentials */
|
||||
credentials?: BedrockCredentials;
|
||||
/** Custom endpoint host for reverse proxy */
|
||||
endpointHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for Bedrock getOptions function
|
||||
*/
|
||||
export interface BedrockLLMConfigResult {
|
||||
llmConfig: BedrockConverseInput & {
|
||||
region?: string;
|
||||
client?: BedrockRuntimeClient;
|
||||
credentials?: BedrockCredentials;
|
||||
endpointHost?: string;
|
||||
};
|
||||
configOptions: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -1,3 +1,64 @@
|
|||
import type { TConfig } from 'librechat-data-provider';
|
||||
import type { ClientOptions, OpenAIClientOptions } from '@librechat/agents';
|
||||
import type { TEndpoint } from 'librechat-data-provider';
|
||||
import type { EndpointTokenConfig, ServerRequest } from '~/types';
|
||||
|
||||
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TConfig, 'order'> }>;
|
||||
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TEndpoint, 'order'> }>;
|
||||
|
||||
/**
|
||||
* Interface for user key values retrieved from the database
|
||||
*/
|
||||
export interface UserKeyValues {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function type for getting user key (single decrypted value)
|
||||
*/
|
||||
export type GetUserKeyFunction = (params: { userId: string; name: string }) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Function type for getting user key values (parsed JSON object with apiKey/baseURL)
|
||||
*/
|
||||
export type GetUserKeyValuesFunction = (params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}) => Promise<UserKeyValues>;
|
||||
|
||||
/**
|
||||
* Database methods required for endpoint initialization
|
||||
* These are passed in at invocation time to allow for dependency injection
|
||||
*/
|
||||
export interface EndpointDbMethods {
|
||||
/** Get single decrypted key value (used for simple API keys) */
|
||||
getUserKey: GetUserKeyFunction;
|
||||
/** Get parsed key values object (used for apiKey + baseURL combinations) */
|
||||
getUserKeyValues: GetUserKeyValuesFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base parameters for all endpoint initialization functions
|
||||
*/
|
||||
export interface BaseInitializeParams {
|
||||
/** Request data containing user and body information (includes req.config) */
|
||||
req: ServerRequest;
|
||||
/** The endpoint name/identifier (e.g., 'openAI', 'anthropic', 'custom-endpoint-name') */
|
||||
endpoint: string;
|
||||
/** Model parameters from the request (includes model, temperature, topP, etc.) */
|
||||
model_parameters?: Record<string, unknown>;
|
||||
/** Database methods for user key operations */
|
||||
db: EndpointDbMethods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base result type that all initialize functions return
|
||||
* Using a more permissive type to accommodate different provider-specific results
|
||||
*/
|
||||
export interface InitializeResultBase {
|
||||
llmConfig: ClientOptions;
|
||||
configOptions?: OpenAIClientOptions['configuration'];
|
||||
endpointTokenConfig?: EndpointTokenConfig;
|
||||
useLegacyContent?: boolean;
|
||||
provider?: string;
|
||||
tools?: unknown[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { AuthKeys, googleBaseSchema } from 'librechat-data-provider';
|
|||
export type GoogleParameters = z.infer<typeof googleBaseSchema>;
|
||||
|
||||
export type GoogleCredentials = {
|
||||
[AuthKeys.GOOGLE_SERVICE_KEY]?: string;
|
||||
[AuthKeys.GOOGLE_SERVICE_KEY]?: string | Record<string, unknown>;
|
||||
[AuthKeys.GOOGLE_API_KEY]?: string;
|
||||
};
|
||||
|
||||
|
|
@ -23,4 +23,8 @@ export interface GoogleConfigOptions {
|
|||
defaultParams?: Record<string, unknown>;
|
||||
addParams?: Record<string, unknown>;
|
||||
dropParams?: string[];
|
||||
/** Stream rate delay for controlling token streaming speed */
|
||||
streamRate?: number;
|
||||
/** Model to use for title generation */
|
||||
titleModel?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ export type RequestBody = {
|
|||
fileTokenLimit?: number;
|
||||
conversationId?: string;
|
||||
parentMessageId?: string;
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type ServerRequest = Request<unknown, unknown, RequestBody> & {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
export * from './anthropic';
|
||||
export * from './azure';
|
||||
export * from './balance';
|
||||
export * from './bedrock';
|
||||
export * from './endpoints';
|
||||
export * from './events';
|
||||
export * from './error';
|
||||
export * from './events';
|
||||
export * from './files';
|
||||
export * from './google';
|
||||
export * from './http';
|
||||
export * from './mistral';
|
||||
export * from './openai';
|
||||
export type * from './openai';
|
||||
export * from './prompts';
|
||||
export * from './run';
|
||||
export * from './anthropic';
|
||||
export * from './tokens';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
import { openAISchema, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption, TAzureConfig, TEndpoint, TConfig } from 'librechat-data-provider';
|
||||
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
|
||||
import { openAISchema } from 'librechat-data-provider';
|
||||
import type { TConfig } from 'librechat-data-provider';
|
||||
import type { OpenAIClientOptions, Providers } from '@librechat/agents';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
|
||||
import type { AzureOptions } from './azure';
|
||||
|
||||
export type OpenAIParameters = z.infer<typeof openAISchema>;
|
||||
|
|
@ -46,58 +45,3 @@ export interface LLMConfigResult<T = OAIClientOptions> {
|
|||
export type OpenAIConfigResult = LLMConfigResult<OAIClientOptions> & {
|
||||
configOptions?: OpenAIConfiguration;
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for user values retrieved from the database
|
||||
*/
|
||||
export interface UserKeyValues {
|
||||
apiKey?: string;
|
||||
baseURL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request interface with only the properties we need (avoids Express typing conflicts)
|
||||
*/
|
||||
export interface RequestData {
|
||||
user: {
|
||||
id: string;
|
||||
};
|
||||
body: {
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
key?: string;
|
||||
};
|
||||
app: {
|
||||
locals: {
|
||||
[EModelEndpoint.azureOpenAI]?: TAzureConfig;
|
||||
[EModelEndpoint.openAI]?: TEndpoint;
|
||||
all?: TEndpoint;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Function type for getting user key values
|
||||
*/
|
||||
export type GetUserKeyValuesFunction = (params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}) => Promise<UserKeyValues>;
|
||||
|
||||
/**
|
||||
* Function type for checking user key expiry
|
||||
*/
|
||||
export type CheckUserKeyExpiryFunction = (expiresAt: string, endpoint: string) => void;
|
||||
|
||||
/**
|
||||
* Parameters for the initializeOpenAI function
|
||||
*/
|
||||
export interface InitializeOpenAIOptionsParams {
|
||||
req: RequestData;
|
||||
appConfig: AppConfig;
|
||||
overrideModel?: string;
|
||||
overrideEndpoint?: string;
|
||||
endpointOption: Partial<TEndpointOption>;
|
||||
getUserKeyValues: GetUserKeyValuesFunction;
|
||||
checkUserKeyExpiry: CheckUserKeyExpiryFunction;
|
||||
}
|
||||
|
|
|
|||
17
packages/api/src/types/tokens.ts
Normal file
17
packages/api/src/types/tokens.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/** Configuration object mapping model keys to their respective prompt, completion rates, and context limit
|
||||
*
|
||||
* Note: the [key: string]: unknown is not in the original JSDoc typedef in /api/typedefs.js, but I've included it since
|
||||
* getModelMaxOutputTokens calls getModelTokenValue with a key of 'output', which was not in the original JSDoc typedef,
|
||||
* but would be referenced in a TokenConfig in the if(matchedPattern) portion of getModelTokenValue.
|
||||
* So in order to preserve functionality for that case and any others which might reference an additional key I'm unaware of,
|
||||
* I've included it here until the interface can be typed more tightly.
|
||||
*/
|
||||
export interface TokenConfig {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
context: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */
|
||||
export type EndpointTokenConfig = Record<string, TokenConfig>;
|
||||
|
|
@ -21,4 +21,5 @@ export { default as Tokenizer, countTokens } from './tokenizer';
|
|||
export * from './yaml';
|
||||
export * from './http';
|
||||
export * from './tokens';
|
||||
export * from './url';
|
||||
export * from './message';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { ErrorTypes } from 'librechat-data-provider';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { readFileAsString } from './files';
|
||||
|
||||
|
|
@ -114,3 +115,25 @@ export async function loadServiceKey(keyPath: string): Promise<GoogleServiceKey
|
|||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user key has expired based on the provided expiration date and endpoint.
|
||||
* If the key has expired, it throws an Error with details including the type of error,
|
||||
* the expiration date, and the endpoint.
|
||||
*
|
||||
* @param expiresAt - The expiration date of the user key in a format that can be parsed by the Date constructor
|
||||
* @param endpoint - The endpoint associated with the user key to be checked
|
||||
* @throws Error if the user key has expired. The error message is a stringified JSON object
|
||||
* containing the type of error (`ErrorTypes.EXPIRED_USER_KEY`), the expiration date in the local string format, and the endpoint.
|
||||
*/
|
||||
export function checkUserKeyExpiry(expiresAt: string, endpoint: string): void {
|
||||
const expiresAtDate = new Date(expiresAt);
|
||||
if (expiresAtDate < new Date()) {
|
||||
const errorMessage = JSON.stringify({
|
||||
type: ErrorTypes.EXPIRED_USER_KEY,
|
||||
expiredAt: expiresAtDate.toLocaleString(),
|
||||
endpoint,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
import z from 'zod';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
/** Configuration object mapping model keys to their respective prompt, completion rates, and context limit
|
||||
*
|
||||
* Note: the [key: string]: unknown is not in the original JSDoc typedef in /api/typedefs.js, but I've included it since
|
||||
* getModelMaxOutputTokens calls getModelTokenValue with a key of 'output', which was not in the original JSDoc typedef,
|
||||
* but would be referenced in a TokenConfig in the if(matchedPattern) portion of getModelTokenValue.
|
||||
* So in order to preserve functionality for that case and any others which might reference an additional key I'm unaware of,
|
||||
* I've included it here until the interface can be typed more tightly.
|
||||
*/
|
||||
export interface TokenConfig {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
context: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** An endpoint's config object mapping model keys to their respective prompt, completion rates, and context limit */
|
||||
export type EndpointTokenConfig = Record<string, TokenConfig>;
|
||||
import type { EndpointTokenConfig, TokenConfig } from '~/types';
|
||||
|
||||
const openAIModels = {
|
||||
'o4-mini': 200000,
|
||||
|
|
|
|||
156
packages/api/src/utils/url.spec.ts
Normal file
156
packages/api/src/utils/url.spec.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { extractBaseURL, deriveBaseURL } from './url';
|
||||
|
||||
describe('extractBaseURL', () => {
|
||||
test('should extract base URL up to /v1 for standard endpoints', () => {
|
||||
const url = 'https://localhost:8080/v1/chat/completions';
|
||||
expect(extractBaseURL(url)).toBe('https://localhost:8080/v1');
|
||||
});
|
||||
|
||||
test('should include /openai in the extracted URL when present', () => {
|
||||
const url = 'https://localhost:8080/v1/openai';
|
||||
expect(extractBaseURL(url)).toBe('https://localhost:8080/v1/openai');
|
||||
});
|
||||
|
||||
test('should stop at /openai and not include any additional paths', () => {
|
||||
const url = 'https://fake.open.ai/v1/openai/you-are-cool';
|
||||
expect(extractBaseURL(url)).toBe('https://fake.open.ai/v1/openai');
|
||||
});
|
||||
|
||||
test('should return the correct base URL for official openai endpoints', () => {
|
||||
const url = 'https://api.openai.com/v1/chat/completions';
|
||||
expect(extractBaseURL(url)).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
|
||||
test('should handle URLs with reverse proxy pattern correctly', () => {
|
||||
const url = 'https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/openai/completions';
|
||||
expect(extractBaseURL(url)).toBe(
|
||||
'https://gateway.ai.cloudflare.com/v1/ACCOUNT_TAG/GATEWAY/openai',
|
||||
);
|
||||
});
|
||||
|
||||
test('should return input if the URL does not match the expected pattern', () => {
|
||||
const url = 'https://someotherdomain.com/notv1';
|
||||
expect(extractBaseURL(url)).toBe(url);
|
||||
});
|
||||
|
||||
test('should extract base URL up to /v1 for open.ai standard endpoint', () => {
|
||||
const url = 'https://open.ai/v1/chat';
|
||||
expect(extractBaseURL(url)).toBe('https://open.ai/v1');
|
||||
});
|
||||
|
||||
test('should extract base URL up to /v1 for open.ai standard endpoint with additional path', () => {
|
||||
const url = 'https://open.ai/v1/chat/completions';
|
||||
expect(extractBaseURL(url)).toBe('https://open.ai/v1');
|
||||
});
|
||||
|
||||
test('should handle URLs with ACCOUNT/GATEWAY pattern followed by /openai', () => {
|
||||
const url = 'https://open.ai/v1/ACCOUNT/GATEWAY/openai/completions';
|
||||
expect(extractBaseURL(url)).toBe('https://open.ai/v1/ACCOUNT/GATEWAY/openai');
|
||||
});
|
||||
|
||||
test('should include /openai in the extracted URL with additional segments', () => {
|
||||
const url = '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);
|
||||
});
|
||||
|
||||
test('should return undefined for null or empty input', () => {
|
||||
expect(extractBaseURL('')).toBe(undefined);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(extractBaseURL(null)).toBe(undefined);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(extractBaseURL(undefined)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveBaseURL', () => {
|
||||
test('should extract protocol, hostname and port from a URL', () => {
|
||||
const fullURL = 'https://api.example.com:8080/v1/models';
|
||||
const baseURL = deriveBaseURL(fullURL);
|
||||
expect(baseURL).toBe('https://api.example.com:8080');
|
||||
});
|
||||
|
||||
test('should handle URLs without port', () => {
|
||||
const fullURL = 'https://api.example.com/v1/models';
|
||||
const baseURL = deriveBaseURL(fullURL);
|
||||
expect(baseURL).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
test('should handle HTTP protocol', () => {
|
||||
const fullURL = 'http://localhost:11434/api/tags';
|
||||
const baseURL = deriveBaseURL(fullURL);
|
||||
expect(baseURL).toBe('http://localhost:11434');
|
||||
});
|
||||
|
||||
test('should handle URLs with paths', () => {
|
||||
const fullURL = 'https://api.ollama.com/v1/chat/completions';
|
||||
const baseURL = deriveBaseURL(fullURL);
|
||||
expect(baseURL).toBe('https://api.ollama.com');
|
||||
});
|
||||
|
||||
test('should return the original URL if parsing fails', () => {
|
||||
const invalidURL = 'not-a-valid-url';
|
||||
const result = deriveBaseURL(invalidURL);
|
||||
expect(result).toBe(invalidURL);
|
||||
});
|
||||
|
||||
test('should handle localhost URLs', () => {
|
||||
const fullURL = 'http://localhost:11434';
|
||||
const baseURL = deriveBaseURL(fullURL);
|
||||
expect(baseURL).toBe('http://localhost:11434');
|
||||
});
|
||||
});
|
||||
102
packages/api/src/utils/url.ts
Normal file
102
packages/api/src/utils/url.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { CohereConstants } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Extracts a valid OpenAI baseURL from a given string, matching "url/v1," followed by an optional suffix.
|
||||
* The suffix can be one of several predefined values (e.g., 'openai', 'azure-openai', etc.),
|
||||
* accommodating different proxy patterns like Cloudflare, LiteLLM, etc.
|
||||
* Returns the original URL if no valid pattern is found.
|
||||
*
|
||||
* Examples:
|
||||
* - `https://open.ai/v1/chat` -> `https://open.ai/v1`
|
||||
* - `https://open.ai/v1/chat/completions` -> `https://open.ai/v1`
|
||||
* - `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://api.example.com/v1/replicate` -> `https://api.example.com/v1/replicate`
|
||||
*
|
||||
* @param url - The URL to be processed.
|
||||
* @returns The matched pattern or input if no match is found.
|
||||
*/
|
||||
export function extractBaseURL(url: string): string | null | undefined {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.startsWith(CohereConstants.API_URL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!url.includes('/v1')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const v1Index = url.indexOf('/v1');
|
||||
let baseUrl = url.substring(0, v1Index + 3);
|
||||
|
||||
const openai = 'openai';
|
||||
const suffixes = [
|
||||
'azure-openai',
|
||||
openai,
|
||||
'aws-bedrock',
|
||||
'anthropic',
|
||||
'cohere',
|
||||
'deepseek',
|
||||
'google-ai-studio',
|
||||
'google-vertex-ai',
|
||||
'grok',
|
||||
'groq',
|
||||
'mistral',
|
||||
'openrouter',
|
||||
'perplexity-ai',
|
||||
'replicate',
|
||||
'huggingface',
|
||||
'workers-ai',
|
||||
'aws-bedrock',
|
||||
];
|
||||
const suffixUsed = suffixes.find((suffix) => url.includes(`/${suffix}`));
|
||||
|
||||
if (suffixUsed === 'azure-openai') {
|
||||
return url.split(/\/(chat|completion)/)[0];
|
||||
}
|
||||
|
||||
const openaiIndex = url.indexOf(`/${openai}`, v1Index + 3);
|
||||
const suffixIndex =
|
||||
suffixUsed === openai ? openaiIndex : url.indexOf(`/${suffixUsed}`, v1Index + 3);
|
||||
|
||||
if (openaiIndex === v1Index + 3) {
|
||||
const nextSlashIndex = url.indexOf('/', openaiIndex + 7);
|
||||
if (nextSlashIndex === -1) {
|
||||
baseUrl = url.substring(0, openaiIndex + 7);
|
||||
} else {
|
||||
baseUrl = url.substring(0, nextSlashIndex);
|
||||
}
|
||||
} else if (suffixIndex > 0) {
|
||||
baseUrl = url.substring(0, suffixIndex + (suffixUsed?.length ?? 0) + 1);
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the base URL (protocol + hostname + port) from the provided URL.
|
||||
* Used primarily for Ollama endpoints to derive the host.
|
||||
* @param fullURL - The full URL.
|
||||
* @returns The base URL (protocol://hostname:port).
|
||||
*/
|
||||
export function deriveBaseURL(fullURL: string): string {
|
||||
try {
|
||||
const parsedUrl = new URL(fullURL);
|
||||
const protocol = parsedUrl.protocol;
|
||||
const hostname = parsedUrl.hostname;
|
||||
const port = parsedUrl.port;
|
||||
|
||||
if (!protocol || !hostname) {
|
||||
return fullURL;
|
||||
}
|
||||
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
|
||||
} catch (error) {
|
||||
logger.error('Failed to derive base URL', error);
|
||||
return fullURL;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue