feat: lora for deployment
This commit is contained in:
@@ -16,7 +16,8 @@ import {
|
||||
GPUListItem,
|
||||
ListItem,
|
||||
ModelInstanceFormData,
|
||||
ModelInstanceListItem
|
||||
ModelInstanceListItem,
|
||||
ModelLoraAdapterResult
|
||||
} from '../config/types';
|
||||
|
||||
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 MODEL_LORA_ADAPTER_API = '/models/adapters';
|
||||
|
||||
const setProxyUrl = (url: string) => {
|
||||
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 =====================
|
||||
|
||||
export async function queryModelInstancesList(
|
||||
|
||||
@@ -48,6 +48,16 @@ export type SourceType =
|
||||
| 'local_path'
|
||||
| '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 {
|
||||
image_name?: string;
|
||||
run_command?: string;
|
||||
@@ -66,6 +76,7 @@ export interface FormData {
|
||||
s3_address: string;
|
||||
ollama_library_model_name: string;
|
||||
distributed_inference_across_workers?: boolean;
|
||||
lora_list: LoraListItem[];
|
||||
local_path?: string;
|
||||
model_scope_model_id?: string;
|
||||
model_scope_file_path?: string;
|
||||
@@ -398,3 +409,10 @@ export interface InstanceRestartCount {
|
||||
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 { backendOptionsMap } from '../constants/backend-parameters';
|
||||
import BackendParametersList from './backend-parameters-list';
|
||||
import ModelLoraList from './model-lora-list';
|
||||
|
||||
const AdvanceConfig = () => {
|
||||
const intl = useIntl();
|
||||
@@ -88,6 +89,7 @@ const AdvanceConfig = () => {
|
||||
onDelete={handleDeleteEnvSelector}
|
||||
></LabelSelector>
|
||||
</Form.Item>
|
||||
<ModelLoraList></ModelLoraList>
|
||||
{(backend === backendOptionsMap.custom ||
|
||||
!currentBackendOptions?.isBuiltIn) && (
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
BackendOption,
|
||||
DeployFormKey,
|
||||
FormData,
|
||||
LoraListItem,
|
||||
SourceType
|
||||
} from '../config/types';
|
||||
import { backendOptionsMap } from '../constants/backend-parameters';
|
||||
@@ -216,6 +217,14 @@ const DataForm: React.FC<DataFormProps> = forwardRef((props, ref) => {
|
||||
const handleOk = async (formdata: FormData) => {
|
||||
const data = _.cloneDeep(formdata);
|
||||
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 allValues = {
|
||||
..._.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