🅰️ feat: Azure OpenAI Assistants API Support (#1992)

* chore: rename dir from `assistant` to plural

* feat: `assistants` field for azure config, spread options in AppService

* refactor: rename constructAzureURL param for azure as `azureOptions`

* chore: bump openai and bun

* chore(loadDefaultModels): change naming of assistant -> assistants

* feat: load azure settings with currect baseURL for assistants' initializeClient

* refactor: add `assistants` flags to groups and model configs, add mapGroupToAzureConfig

* feat(loadConfigEndpoints): initialize assistants endpoint if azure flag `assistants` is enabled

* feat(AppService): determine assistant models on startup, throw Error if none

* refactor(useDeleteAssistantMutation): send model along with assistant id for delete mutations

* feat: support listing and deleting assistants with azure

* feat: add model query to assistant avatar upload

* feat: add azure support for retrieveRun method

* refactor: update OpenAIClient initialization

* chore: update README

* fix(ci): tests passing

* refactor(uploadOpenAIFile): improve logging and use more efficient REST API method

* refactor(useFileHandling): add model to metadata to target Azure region compatible with current model

* chore(files): add azure naming pattern for valid file id recognition

* fix(assistants): initialize openai with first available assistant model if none provided

* refactor(uploadOpenAIFile): add content type for azure, initialize formdata before azure options

* refactor(sleep): move sleep function out of Runs and into `~/server/utils`

* fix(azureOpenAI/assistants): make sure to only overwrite models with assistant models if `assistants` flag is enabled

* refactor(uploadOpenAIFile): revert to old method

* chore(uploadOpenAIFile): use enum for file purpose

* docs: azureOpenAI update guide with more info, examples

* feat: enable/disable assistant capabilities and specify retrieval models

* refactor: optional chain conditional statement in loadConfigModels.js

* docs: add assistants examples

* chore: update librechat.example.yaml

* docs(azure): update note of file upload behavior in Azure OpenAI Assistants

* chore: update docs and add descriptive message about assistant errors

* fix: prevent message submission with invalid assistant or if files loading

* style: update Landing icon & text when assistant is not selected

* chore: bump librechat-data-provider to 0.4.8

* fix(assistants/azure): assign req.body.model for proper azure init to abort runs
This commit is contained in:
Danny Avila 2024-03-14 17:21:42 -04:00 committed by GitHub
parent 1b243c6f8c
commit 5cd5c3bef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 1044 additions and 300 deletions

View file

@ -1,16 +1,17 @@
import { useRecoilState } from 'recoil';
import { memo, useCallback, useRef } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { useForm } from 'react-hook-form';
import TextareaAutosize from 'react-textarea-autosize';
import { memo, useCallback, useRef, useMemo } from 'react';
import {
supportsFiles,
mergeFileConfig,
fileConfig as defaultFileConfig,
EModelEndpoint,
} from 'librechat-data-provider';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useRequiresKey, useTextarea } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusOutlines } from '~/utils';
import { useChatContext } from '~/Providers';
import AttachFile from './Files/AttachFile';
import StopButton from './StopButton';
import SendButton from './SendButton';
@ -37,6 +38,7 @@ const ChatForm = ({ index = 0 }) => {
setFilesLoading,
} = useChatContext();
const assistantMap = useAssistantsMapContext();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
@ -61,6 +63,16 @@ const ChatForm = ({ index = 0 }) => {
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
const invalidAssistant = useMemo(
() =>
conversation?.endpoint === EModelEndpoint.assistants &&
(!conversation?.assistant_id || !assistantMap?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(
() => !!(requiresKey || invalidAssistant),
[requiresKey, invalidAssistant],
);
return (
<form
@ -92,7 +104,7 @@ const ChatForm = ({ index = 0 }) => {
ref={(e) => {
textAreaRef.current = e;
}}
disabled={!!requiresKey}
disabled={disableInputs}
onPaste={handlePaste}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
@ -116,7 +128,7 @@ const ChatForm = ({ index = 0 }) => {
<AttachFile
endpoint={_endpoint ?? ''}
endpointType={endpointType}
disabled={requiresKey}
disabled={disableInputs}
/>
{isSubmitting && showStopButton ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
@ -125,7 +137,7 @@ const ChatForm = ({ index = 0 }) => {
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || requiresKey)}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
/>
)
)}

View file

@ -87,7 +87,9 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
</div>
) : (
<div className="mb-5 text-2xl font-medium dark:text-white">
{localize('com_nav_welcome_message')}
{endpoint === EModelEndpoint.assistants
? localize('com_nav_welcome_assistant')
: localize('com_nav_welcome_message')}
</div>
)}
</div>

View file

@ -48,7 +48,7 @@ export const icons = {
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
}
return <Sparkles className={className} />;
return <Sparkles className={cn(assistantName === '' ? 'icon-2xl' : '', className)} />;
},
unknown: UnknownIcon,
};

View file

@ -1,5 +1,5 @@
import * as Popover from '@radix-ui/react-popover';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
fileConfig as defaultFileConfig,
@ -16,7 +16,7 @@ import type {
} from 'librechat-data-provider';
import { useUploadAssistantAvatarMutation, useGetFileConfig } from '~/data-provider';
import { AssistantAvatar, NoImage, AvatarMenu } from './Images';
import { useToastContext } from '~/Providers';
import { useToastContext, useAssistantsMapContext } from '~/Providers';
// import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
// import { cn } from '~/utils/';
@ -32,6 +32,7 @@ function Avatar({
}) {
// console.log('Avatar', assistant_id, metadata, createMutation);
const queryClient = useQueryClient();
const assistantsMap = useAssistantsMapContext();
const [menuOpen, setMenuOpen] = useState(false);
const [progress, setProgress] = useState<number>(1);
const [input, setInput] = useState<File | null>(null);
@ -44,6 +45,10 @@ function Avatar({
const localize = useLocalize();
const { showToast } = useToastContext();
const activeModel = useMemo(() => {
return assistantsMap[assistant_id ?? '']?.model ?? '';
}, [assistant_id, assistantsMap]);
const { mutate: uploadAvatar } = useUploadAssistantAvatarMutation({
onMutate: () => {
setProgress(0.4);
@ -141,11 +146,12 @@ function Avatar({
uploadAvatar({
assistant_id: createMutation.data.id,
model: activeModel,
postCreation: true,
formData,
});
}
}, [createMutation.data, createMutation.isSuccess, input, previewUrl, uploadAvatar]);
}, [createMutation.data, createMutation.isSuccess, input, previewUrl, uploadAvatar, activeModel]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
@ -175,6 +181,7 @@ function Avatar({
uploadAvatar({
assistant_id,
model: activeModel,
formData,
});
} else {

View file

@ -1,17 +1,17 @@
import { useState, useMemo, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import { useForm, FormProvider, Controller, useWatch } from 'react-hook-form';
import { useGetModelsQuery, useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import {
Tools,
QueryKeys,
Capabilities,
EModelEndpoint,
actionDelimiter,
supportsRetrieval,
defaultAssistantFormValues,
} from 'librechat-data-provider';
import type { FunctionTool, TPlugin } from 'librechat-data-provider';
import type { AssistantForm, AssistantPanelProps } from '~/common';
import type { FunctionTool, TPlugin, TEndpointsConfig } from 'librechat-data-provider';
import { useCreateAssistantMutation, useUpdateAssistantMutation } from '~/data-provider';
import { SelectDropDown, Checkbox, QuestionMark } from '~/components/ui';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
@ -42,7 +42,7 @@ export default function AssistantPanel({
const queryClient = useQueryClient();
const modelsQuery = useGetModelsQuery();
const assistantMap = useAssistantsMapContext();
const [showToolDialog, setShowToolDialog] = useState(false);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
const { onSelect: onSelectAssistant } = useSelectAssistant();
const { showToast } = useToastContext();
@ -51,17 +51,43 @@ export default function AssistantPanel({
const methods = useForm<AssistantForm>({
defaultValues: defaultAssistantFormValues,
});
const [showToolDialog, setShowToolDialog] = useState(false);
const { control, handleSubmit, reset, setValue, getValues } = methods;
const assistant_id = useWatch({ control, name: 'id' });
const assistant = useWatch({ control, name: 'assistant' });
const functions = useWatch({ control, name: 'functions' });
const assistant_id = useWatch({ control, name: 'id' });
const model = useWatch({ control, name: 'model' });
const activeModel = useMemo(() => {
return assistantMap?.[assistant_id]?.model;
}, [assistantMap, assistant_id]);
const assistants = useMemo(() => endpointsConfig?.[EModelEndpoint.assistants], [endpointsConfig]);
const retrievalModels = useMemo(() => new Set(assistants?.retrievalModels ?? []), [assistants]);
const toolsEnabled = useMemo(
() => assistants?.capabilities?.includes(Capabilities.tools),
[assistants],
);
const actionsEnabled = useMemo(
() => assistants?.capabilities?.includes(Capabilities.actions),
[assistants],
);
const retrievalEnabled = useMemo(
() => assistants?.capabilities?.includes(Capabilities.retrieval),
[assistants],
);
const codeEnabled = useMemo(
() => assistants?.capabilities?.includes(Capabilities.code_interpreter),
[assistants],
);
useEffect(() => {
if (model && !supportsRetrieval.has(model)) {
setValue('retrieval', false);
if (model && !retrievalModels.has(model)) {
setValue(Capabilities.retrieval, false);
}
}, [model, setValue]);
}, [model, setValue, retrievalModels]);
/* Mutations */
const update = useUpdateAssistantMutation({
@ -300,78 +326,96 @@ export default function AssistantPanel({
/>
</div>
{/* Knowledge */}
<Knowledge assistant_id={assistant_id} files={files} />
{(codeEnabled || retrievalEnabled) && (
<Knowledge assistant_id={assistant_id} files={files} />
)}
{/* Capabilities */}
<div className="mb-6">
<div className="mb-1.5 flex items-center">
<span>
<label className="text-token-text-primary block font-medium">Capabilities</label>
<label className="text-token-text-primary block font-medium">
{localize('com_assistants_capabilities')}
</label>
</span>
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex items-center">
<Controller
name={'code_interpreter'}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="code_interpreter"
onClick={() =>
setValue('code_interpreter', !getValues('code_interpreter'), {
shouldDirty: true,
})
}
>
<div className="flex items-center">
{localize('com_assistants_code_interpreter')}
<QuestionMark />
</div>
</label>
</div>
<div className="flex items-center">
<Controller
name={'retrieval'}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
disabled={!supportsRetrieval.has(model)}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className={cn(
'form-check-label text-token-text-primary w-full',
!supportsRetrieval.has(model) ? 'cursor-no-drop opacity-50' : 'cursor-pointer',
)}
htmlFor="retrieval"
onClick={() =>
supportsRetrieval.has(model) &&
setValue('retrieval', !getValues('retrieval'), { shouldDirty: true })
}
>
{localize('com_assistants_retrieval')}
</label>
</div>
{codeEnabled && (
<div className="flex items-center">
<Controller
name={Capabilities.code_interpreter}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor={Capabilities.code_interpreter}
onClick={() =>
setValue(
Capabilities.code_interpreter,
!getValues(Capabilities.code_interpreter),
{
shouldDirty: true,
},
)
}
>
<div className="flex items-center">
{localize('com_assistants_code_interpreter')}
<QuestionMark />
</div>
</label>
</div>
)}
{retrievalEnabled && (
<div className="flex items-center">
<Controller
name={Capabilities.retrieval}
control={control}
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
disabled={!retrievalModels.has(model)}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={field?.value?.toString()}
/>
)}
/>
<label
className={cn(
'form-check-label text-token-text-primary w-full',
!retrievalModels.has(model) ? 'cursor-no-drop opacity-50' : 'cursor-pointer',
)}
htmlFor={Capabilities.retrieval}
onClick={() =>
retrievalModels.has(model) &&
setValue(Capabilities.retrieval, !getValues(Capabilities.retrieval), {
shouldDirty: true,
})
}
>
{localize('com_assistants_retrieval')}
</label>
</div>
)}
</div>
</div>
{/* Tools */}
<div className="mb-6">
<label className={labelClass}>{localize('com_assistants_tools_section')}</label>
<label className={labelClass}>
{`${toolsEnabled ? localize('com_assistants_tools') : ''}
${toolsEnabled && actionsEnabled ? ' + ' : ''}
${actionsEnabled ? localize('com_assistants_actions') : ''}`}
</label>
<div className="space-y-1">
{functions.map((func) => (
<AssistantTool
@ -388,39 +432,44 @@ export default function AssistantPanel({
<AssistantAction key={i} action={action} onClick={() => setAction(action)} />
);
})}
<button
type="button"
onClick={() => setShowToolDialog(true)}
className="btn border-token-border-light relative mx-1 mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_tools')}
</div>
</button>
<button
type="button"
disabled={!assistant_id}
onClick={() => {
if (!assistant_id) {
return showToast({
message: localize('com_assistants_actions_disabled'),
status: 'warning',
});
}
setActivePanel(Panel.actions);
}}
className="btn border-token-border-light relative mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_actions')}
</div>
</button>
{toolsEnabled && (
<button
type="button"
onClick={() => setShowToolDialog(true)}
className="btn border-token-border-light relative mx-1 mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_tools')}
</div>
</button>
)}
{actionsEnabled && (
<button
type="button"
disabled={!assistant_id}
onClick={() => {
if (!assistant_id) {
return showToast({
message: localize('com_assistants_actions_disabled'),
status: 'warning',
});
}
setActivePanel(Panel.actions);
}}
className="btn border-token-border-light relative mt-2 h-8 rounded-lg bg-transparent font-medium hover:bg-gray-100 dark:hover:bg-gray-800"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_actions')}
</div>
</button>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2">
{/* Context Button */}
<ContextButton
assistant_id={assistant_id}
activeModel={activeModel}
setCurrentAssistantId={setCurrentAssistantId}
createMutation={create}
/>

View file

@ -10,10 +10,12 @@ import { NewTrashIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
export default function ContextButton({
activeModel,
assistant_id,
setCurrentAssistantId,
createMutation,
}: {
activeModel: string;
assistant_id: string;
setCurrentAssistantId: React.Dispatch<React.SetStateAction<string | undefined>>;
createMutation: UseMutationResult<Assistant, Error, AssistantCreateParams>;
@ -136,7 +138,7 @@ export default function ContextButton({
</>
}
selection={{
selectHandler: () => deleteAssistant.mutate({ assistant_id }),
selectHandler: () => deleteAssistant.mutate({ assistant_id, model: activeModel }),
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}