feat: lora for deployment
This commit is contained in:
@@ -16,7 +16,8 @@ import {
|
|||||||
GPUListItem,
|
GPUListItem,
|
||||||
ListItem,
|
ListItem,
|
||||||
ModelInstanceFormData,
|
ModelInstanceFormData,
|
||||||
ModelInstanceListItem
|
ModelInstanceListItem,
|
||||||
|
ModelLoraAdapterResult
|
||||||
} from '../config/types';
|
} from '../config/types';
|
||||||
|
|
||||||
export const MODELS_API = '/models';
|
export const MODELS_API = '/models';
|
||||||
@@ -33,6 +34,8 @@ export const DRAFT_MODELS_API = '/draft-models';
|
|||||||
|
|
||||||
export const CATALOG_LIST_API = '/model-sets';
|
export const CATALOG_LIST_API = '/model-sets';
|
||||||
|
|
||||||
|
export const MODEL_LORA_ADAPTER_API = '/models/adapters';
|
||||||
|
|
||||||
const setProxyUrl = (url: string) => {
|
const setProxyUrl = (url: string) => {
|
||||||
return `/proxy?url=${encodeURIComponent(url)}`;
|
return `/proxy?url=${encodeURIComponent(url)}`;
|
||||||
};
|
};
|
||||||
@@ -100,6 +103,21 @@ export async function queryModelDetail(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryModelLoraAdapter(
|
||||||
|
params: {
|
||||||
|
base: string;
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
options?: any
|
||||||
|
) {
|
||||||
|
return request<ModelLoraAdapterResult>(`${MODEL_LORA_ADAPTER_API}`, {
|
||||||
|
params,
|
||||||
|
cancelToken: options?.token,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== Model Instances start =====================
|
// ===================== Model Instances start =====================
|
||||||
|
|
||||||
export async function queryModelInstancesList(
|
export async function queryModelInstancesList(
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export type SourceType =
|
|||||||
| 'local_path'
|
| 'local_path'
|
||||||
| 'ollama_library';
|
| 'ollama_library';
|
||||||
|
|
||||||
|
export interface LoraListItem {
|
||||||
|
lora_name: string;
|
||||||
|
lora_repo_name: string;
|
||||||
|
source: 'huggingface' | 'model_scope';
|
||||||
|
huggingface_filename: string;
|
||||||
|
model_scope_file_path: string;
|
||||||
|
local_path: string;
|
||||||
|
path: string;
|
||||||
|
model_file_id: number;
|
||||||
|
}
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
image_name?: string;
|
image_name?: string;
|
||||||
run_command?: string;
|
run_command?: string;
|
||||||
@@ -66,6 +76,7 @@ export interface FormData {
|
|||||||
s3_address: string;
|
s3_address: string;
|
||||||
ollama_library_model_name: string;
|
ollama_library_model_name: string;
|
||||||
distributed_inference_across_workers?: boolean;
|
distributed_inference_across_workers?: boolean;
|
||||||
|
lora_list: LoraListItem[];
|
||||||
local_path?: string;
|
local_path?: string;
|
||||||
model_scope_model_id?: string;
|
model_scope_model_id?: string;
|
||||||
model_scope_file_path?: string;
|
model_scope_file_path?: string;
|
||||||
@@ -398,3 +409,10 @@ export interface InstanceRestartCount {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelLoraAdapterResult {
|
||||||
|
lora_list: Array<{
|
||||||
|
lora_repo_name: string;
|
||||||
|
source: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useFormContext } from '../config/form-context';
|
|||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
import { backendOptionsMap } from '../constants/backend-parameters';
|
import { backendOptionsMap } from '../constants/backend-parameters';
|
||||||
import BackendParametersList from './backend-parameters-list';
|
import BackendParametersList from './backend-parameters-list';
|
||||||
|
import ModelLoraList from './model-lora-list';
|
||||||
|
|
||||||
const AdvanceConfig = () => {
|
const AdvanceConfig = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -88,6 +89,7 @@ const AdvanceConfig = () => {
|
|||||||
onDelete={handleDeleteEnvSelector}
|
onDelete={handleDeleteEnvSelector}
|
||||||
></LabelSelector>
|
></LabelSelector>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<ModelLoraList></ModelLoraList>
|
||||||
{(backend === backendOptionsMap.custom ||
|
{(backend === backendOptionsMap.custom ||
|
||||||
!currentBackendOptions?.isBuiltIn) && (
|
!currentBackendOptions?.isBuiltIn) && (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
BackendOption,
|
BackendOption,
|
||||||
DeployFormKey,
|
DeployFormKey,
|
||||||
FormData,
|
FormData,
|
||||||
|
LoraListItem,
|
||||||
SourceType
|
SourceType
|
||||||
} from '../config/types';
|
} from '../config/types';
|
||||||
import { backendOptionsMap } from '../constants/backend-parameters';
|
import { backendOptionsMap } from '../constants/backend-parameters';
|
||||||
@@ -216,6 +217,14 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
|||||||
const handleOk = async (formdata: FormData) => {
|
const handleOk = async (formdata: FormData) => {
|
||||||
const data = _.cloneDeep(formdata);
|
const data = _.cloneDeep(formdata);
|
||||||
data.categories = data.categories ? [data.categories] : [];
|
data.categories = data.categories ? [data.categories] : [];
|
||||||
|
if (data.lora_list && data.lora_list.length > 0) {
|
||||||
|
data.lora_list = data.lora_list.map((item: LoraListItem) => ({
|
||||||
|
...item,
|
||||||
|
huggingface_filename: data.huggingface_filename || '',
|
||||||
|
model_scope_file_path: data.model_scope_file_path || '',
|
||||||
|
local_path: data.local_path || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
const gpuSelector = generateGPUIds(data);
|
const gpuSelector = generateGPUIds(data);
|
||||||
const allValues = {
|
const allValues = {
|
||||||
..._.omit(data, ['scheduleType']),
|
..._.omit(data, ['scheduleType']),
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
AutoTooltip,
|
||||||
|
Input as CInput,
|
||||||
|
Cascader as SealCascader
|
||||||
|
} from '@gpustack/core-ui';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { modelSourceMap } from '../config';
|
||||||
|
import useQueryModelLoraList from '../services/use-query-lora-list';
|
||||||
|
|
||||||
|
type LoraDataItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
lora_repo_name: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoraListItemProps {
|
||||||
|
item: { value: any[]; lora_name: string };
|
||||||
|
base: string;
|
||||||
|
defaultDataList: LoraDataItem[];
|
||||||
|
selectedRepoNames: Set<string>;
|
||||||
|
onChange: (partial: { value?: any[]; lora_name?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = (source: string) => {
|
||||||
|
if (source === modelSourceMap.huggingface_value) {
|
||||||
|
return modelSourceMap.huggingface;
|
||||||
|
}
|
||||||
|
if (source === modelSourceMap.modelscope_value) {
|
||||||
|
return modelSourceMap.modelScope;
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoraListItem: React.FC<LoraListItemProps> = ({
|
||||||
|
item,
|
||||||
|
base,
|
||||||
|
defaultDataList,
|
||||||
|
selectedRepoNames,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const { dataList: ownSearchList, fetchData } = useQueryModelLoraList();
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
const itemDataList = hasSearched ? ownSearchList : defaultDataList;
|
||||||
|
|
||||||
|
const debouncedSearch = useMemo(
|
||||||
|
() =>
|
||||||
|
_.debounce((q: string) => {
|
||||||
|
fetchData({ base, q });
|
||||||
|
}, 300),
|
||||||
|
[base]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasSearched(false);
|
||||||
|
}, [base]);
|
||||||
|
|
||||||
|
const groupedOptions = useMemo(() => {
|
||||||
|
const groups: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
isParent: boolean;
|
||||||
|
children: any[];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
const currentRepo = item.value?.[1];
|
||||||
|
|
||||||
|
itemDataList.forEach((it) => {
|
||||||
|
if (!groups[it.source]) {
|
||||||
|
groups[it.source] = {
|
||||||
|
label: sourceLabel(it.source),
|
||||||
|
value: it.source,
|
||||||
|
isParent: true,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const isSelectedByOther =
|
||||||
|
selectedRepoNames.has(it.lora_repo_name) &&
|
||||||
|
it.lora_repo_name !== currentRepo;
|
||||||
|
if (!isSelectedByOther) {
|
||||||
|
groups[it.source].children.push({
|
||||||
|
label: it.lora_repo_name,
|
||||||
|
value: it.lora_repo_name,
|
||||||
|
source: it.source,
|
||||||
|
isParent: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups).filter((g) => g.children.length > 0);
|
||||||
|
}, [itemDataList, selectedRepoNames, item.value]);
|
||||||
|
|
||||||
|
const handleSearch = (q: string) => {
|
||||||
|
if (!base || !q) {
|
||||||
|
setHasSearched(false);
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasSearched(true);
|
||||||
|
debouncedSearch(q);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCascaderChange = (value: any) => {
|
||||||
|
onChange({ value: value || [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({ lora_name: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cascaderEmpty = !item.value || item.value.length === 0;
|
||||||
|
const nameEmpty = !item.lora_name;
|
||||||
|
const cascaderStatus =
|
||||||
|
cascaderEmpty && !nameEmpty ? ('error' as const) : undefined;
|
||||||
|
const inputStatus =
|
||||||
|
nameEmpty && !cascaderEmpty ? ('error' as const) : undefined;
|
||||||
|
|
||||||
|
const displayRender = (labels: any[]) => {
|
||||||
|
return (
|
||||||
|
<AutoTooltip
|
||||||
|
ghost
|
||||||
|
maxWidth={300}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
{labels[0]} / {labels[1]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{labels[0]} / {labels[1]}
|
||||||
|
</span>
|
||||||
|
</AutoTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionNode = (option: any) => {
|
||||||
|
const { data } = option;
|
||||||
|
if (data.isParent) {
|
||||||
|
return (
|
||||||
|
<AutoTooltip ghost maxWidth={100}>
|
||||||
|
{data.label}
|
||||||
|
</AutoTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AutoTooltip ghost maxWidth={200}>
|
||||||
|
{data.label}
|
||||||
|
</AutoTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 120px',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SealCascader
|
||||||
|
showSearch
|
||||||
|
expandTrigger="hover"
|
||||||
|
multiple={false}
|
||||||
|
alwaysFocus={true}
|
||||||
|
status={cascaderStatus}
|
||||||
|
value={item.value}
|
||||||
|
options={groupedOptions}
|
||||||
|
onChange={handleCascaderChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
placeholder="Select LoRA"
|
||||||
|
showCheckedStrategy="SHOW_CHILD"
|
||||||
|
displayRender={displayRender}
|
||||||
|
optionNode={optionNode}
|
||||||
|
classNames={{
|
||||||
|
popup: {
|
||||||
|
root: 'cascader-popup-wrapper'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
popup: {
|
||||||
|
listItem: {
|
||||||
|
padding: '5px 10px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
getPopupContainer={(triggerNode) => triggerNode.parentNode}
|
||||||
|
></SealCascader>
|
||||||
|
<CInput.Input
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
status={inputStatus}
|
||||||
|
value={item.lora_name}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="LoRA name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoraListItem;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { MetadataList } from '@gpustack/core-ui';
|
||||||
|
import { Form } from 'antd';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FormData, LoraListItem } from '../config/types';
|
||||||
|
import useQueryModelLoraList from '../services/use-query-lora-list';
|
||||||
|
import LoraItem from './lora-list-item';
|
||||||
|
|
||||||
|
type ItemValue = { value: any[]; lora_name: string };
|
||||||
|
|
||||||
|
const ModelLoraList = () => {
|
||||||
|
const form = Form.useFormInstance<FormData>();
|
||||||
|
const huggingfaceRepoId = Form.useWatch('huggingface_repo_id', form);
|
||||||
|
const modelScopeModelId = Form.useWatch('model_scope_model_id', form);
|
||||||
|
const localPath = Form.useWatch('local_path', form);
|
||||||
|
|
||||||
|
const base = huggingfaceRepoId || modelScopeModelId || localPath || '';
|
||||||
|
|
||||||
|
const { dataList: defaultDataList, fetchData } = useQueryModelLoraList();
|
||||||
|
|
||||||
|
const [itemList, setItemList] = useState<ItemValue[]>([]);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const prevBaseRef = useRef<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initializedRef.current = true;
|
||||||
|
const existing = (form.getFieldValue('lora_list') || []) as LoraListItem[];
|
||||||
|
if (existing.length > 0) {
|
||||||
|
setItemList(
|
||||||
|
existing.map((it) => ({
|
||||||
|
value:
|
||||||
|
it.source && it.lora_repo_name
|
||||||
|
? [it.source, it.lora_repo_name]
|
||||||
|
: [],
|
||||||
|
lora_name: it.lora_name || ''
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!base) {
|
||||||
|
prevBaseRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData({ base });
|
||||||
|
if (prevBaseRef.current && prevBaseRef.current !== base) {
|
||||||
|
form.setFieldValue('lora_list', []);
|
||||||
|
setItemList([]);
|
||||||
|
}
|
||||||
|
prevBaseRef.current = base;
|
||||||
|
}, [base]);
|
||||||
|
|
||||||
|
const selectedRepoNames = useMemo(() => {
|
||||||
|
return new Set(
|
||||||
|
itemList.map((it) => it.value?.[1]).filter(Boolean) as string[]
|
||||||
|
);
|
||||||
|
}, [itemList]);
|
||||||
|
|
||||||
|
const syncFormField = (newItemList: ItemValue[]) => {
|
||||||
|
const newFormList = newItemList.map((it) => ({
|
||||||
|
source: (it.value?.[0] || '') as 'huggingface' | 'model_scope',
|
||||||
|
lora_repo_name: it.value?.[1] || '',
|
||||||
|
lora_name: it.lora_name || ''
|
||||||
|
}));
|
||||||
|
form.setFieldValue('lora_list', newFormList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (
|
||||||
|
index: number,
|
||||||
|
partial: { value?: any[]; lora_name?: string }
|
||||||
|
) => {
|
||||||
|
const newItemList = [...itemList];
|
||||||
|
newItemList[index] = { ...newItemList[index], ...partial };
|
||||||
|
setItemList(newItemList);
|
||||||
|
syncFormField(newItemList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const newItemList = [...itemList, { value: [], lora_name: '' }];
|
||||||
|
setItemList(newItemList);
|
||||||
|
syncFormField(newItemList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const newItemList = itemList.filter((_, i) => i !== index);
|
||||||
|
setItemList(newItemList);
|
||||||
|
syncFormField(newItemList);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item<FormData> name="lora_list" trigger="">
|
||||||
|
<MetadataList
|
||||||
|
label="LoRA Adapter"
|
||||||
|
dataList={itemList}
|
||||||
|
btnText="Add LoRA Adapter"
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
>
|
||||||
|
{(item, index) => (
|
||||||
|
<LoraItem
|
||||||
|
item={item}
|
||||||
|
base={base}
|
||||||
|
defaultDataList={defaultDataList}
|
||||||
|
selectedRepoNames={selectedRepoNames}
|
||||||
|
onChange={(partial) => handleItemChange(index, partial)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MetadataList>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelLoraList;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useQueryData } from '@gpustack/core-ui';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { queryModelLoraAdapter } from '../apis';
|
||||||
|
import { ModelLoraAdapterResult } from '../config/types';
|
||||||
|
|
||||||
|
type Parameters = {
|
||||||
|
base: string;
|
||||||
|
q?: string;
|
||||||
|
limit?: number; // default to 40
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useQueryModelLoraList = () => {
|
||||||
|
const { detailData, loading, fetchData, cancelRequest } = useQueryData<
|
||||||
|
ModelLoraAdapterResult,
|
||||||
|
Parameters
|
||||||
|
>({
|
||||||
|
key: 'modelLoraList',
|
||||||
|
fetchDetail: queryModelLoraAdapter
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dataList, setDataList] = useState<
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
lora_repo_name: string;
|
||||||
|
source: string;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const getData = (params: Parameters) => {
|
||||||
|
fetchData(params).then((result) => {
|
||||||
|
if (result) {
|
||||||
|
const formattedData = result.lora_list.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: item.lora_repo_name,
|
||||||
|
value: item.lora_repo_name
|
||||||
|
}));
|
||||||
|
setDataList(formattedData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataList,
|
||||||
|
loading,
|
||||||
|
fetchData: getData,
|
||||||
|
cancelRequest
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useQueryModelLoraList;
|
||||||
Reference in New Issue
Block a user