Skip to content

Commit 83cdd86

Browse files
authored
feat: Support configuring multiple model mapping rules for an AI route upstream (higress-group#529)
1 parent 5fffa70 commit 83cdd86

File tree

8 files changed

+320
-21
lines changed

8 files changed

+320
-21
lines changed

frontend/src/interfaces/ai-route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ export interface AiRouteFallbackConfig {
2323
export interface AiUpstream {
2424
provider: string;
2525
weight?: number;
26+
modelMapping?: Record<string, string>;
2627
}

frontend/src/interfaces/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,9 @@ export const fetchPluginsByRoute = async (record: Route): Promise<WasmPluginData
157157
data[record.name] = data[record.name] ? data[record.name].concat(builtInPlugins) : builtInPlugins;
158158
return data[record.name] || [];
159159
};
160+
161+
export enum MatchType {
162+
EQUAL = "EQUAL", // 精确匹配
163+
PRE = "PRE", // 前缀匹配
164+
REGULAR = "REGULAR", // 正则匹配
165+
}

frontend/src/locales/en-US/translation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@
8989
"badWeightSum": "The sum of all service weights must be 100.",
9090
"noUpstreams": "At least one target service is required."
9191
},
92+
"modelMapping": {
93+
"key": "Model Name Match Key",
94+
"target": "Mapped Model Name",
95+
"mappingTargetPlaceholder_full": "If left empty, the model name in the request will be used.",
96+
"mappingTargetPlaceholder_simple": "Empty means unchanged.",
97+
"add": "Add New Mapping",
98+
"default": "Default Model Name",
99+
"advanced": "Model Name Mapping"
100+
},
92101
"modelMatchType": "Model Match Type",
93102
"modelMatchValue": "Match Value",
94103
"byModelName": "By model name",

frontend/src/locales/zh-CN/translation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@
8989
"badWeightSum": "所有服务的权重总和必须为100",
9090
"noUpstreams": "至少要配置一个目标AI服务"
9191
},
92+
"modelMapping": {
93+
"key": "模型名称匹配条件",
94+
"target": "映射后的模型名称",
95+
"mappingTargetPlaceholder_full": "留空表示使用请求中的模型名称",
96+
"mappingTargetPlaceholder_simple": "留空表示不变",
97+
"add": "添加模型名称映射规则",
98+
"default": "默认模型名称",
99+
"advanced": "模型名称映射规则"
100+
},
92101
"modelMatchType": "匹配方式",
93102
"modelMatchValue": "匹配条件",
94103
"byModelName": "按模型名称",

frontend/src/pages/ai/components/RouteForm/Components.tsx

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { RedoOutlined } from '@ant-design/icons';
2-
import { Button, Form } from 'antd';
1+
import { MatchType } from '@/interfaces/route';
2+
import { FormOutlined, MinusCircleOutlined, PlusOutlined, RedoOutlined } from '@ant-design/icons';
3+
import { AutoComplete, Button, Empty, Form, Input, Popover, Select } from 'antd';
4+
import { useState } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
import { modelMapping2String, string2ModelMapping } from './util';
37

48
// 刷新按钮
59
const RedoOutlinedBtn = (props) => {
@@ -28,6 +32,229 @@ const HistoryButton = (props) => {
2832
)
2933
};
3034

35+
// 模型映射编辑器
36+
const ModelMappingEditor = (props) => {
37+
const { t } = useTranslation();
38+
const { value, style, options, onChange } = props;
39+
const [form] = Form.useForm();
40+
const [popoverOpen, setPopoverOpen] = useState(false);
41+
42+
interface ModelMapping {
43+
key: string;
44+
matchType: MatchType;
45+
target: string;
46+
}
47+
48+
const handleChange = (e) => {
49+
onChange && onChange(e);
50+
};
51+
52+
const onConfirm = () => {
53+
downloadFromPopoverForm();
54+
closePopover();
55+
};
56+
57+
const onCancel = () => {
58+
closePopover();
59+
};
60+
61+
const closePopover = () => {
62+
setPopoverOpen(false);
63+
};
64+
65+
const onPopoverOpenChange = (open) => {
66+
setPopoverOpen(open);
67+
open && uploadToPopoverForm();
68+
};
69+
70+
const uploadToPopoverForm = () => {
71+
const modelMappingObj = string2ModelMapping(value);
72+
const formValues = {
73+
defaultMapping: '',
74+
modelMappings: new Array<ModelMapping>(),
75+
}
76+
for (const [key, target] of Object.entries(modelMappingObj)) {
77+
if (!key) {
78+
continue;
79+
}
80+
if (key === '*') {
81+
formValues.defaultMapping = target;
82+
} else if (key.endsWith('*')) {
83+
const prefix = key.replace(/\*+$/, '');
84+
formValues.modelMappings.push({
85+
key: prefix,
86+
matchType: MatchType.PRE,
87+
target,
88+
});
89+
} else {
90+
formValues.modelMappings.push({
91+
key,
92+
matchType: MatchType.EQUAL,
93+
target,
94+
});
95+
}
96+
}
97+
form.setFieldsValue(formValues);
98+
};
99+
100+
const downloadFromPopoverForm = () => {
101+
form.validateFields().then((values) => {
102+
const modelMappingObj: Record<string, string> = {};
103+
const modelMappings = values.modelMappings || [];
104+
for (const modelMapping of modelMappings) {
105+
if (!modelMapping.key || !modelMapping.matchType) {
106+
continue;
107+
}
108+
switch (modelMapping.matchType) {
109+
case MatchType.EQUAL:
110+
modelMappingObj[modelMapping.key] = modelMapping.target || '';
111+
break;
112+
case MatchType.PRE:
113+
modelMappingObj[modelMapping.key + '*'] = modelMapping.target || '';
114+
break;
115+
default:
116+
throw new Error(`Unsupported match type: ${modelMapping.matchType}`);
117+
}
118+
}
119+
if (values.defaultMapping) {
120+
modelMappingObj['*'] = values.defaultMapping;
121+
}
122+
handleChange(modelMapping2String(modelMappingObj));
123+
});
124+
};
125+
126+
const popoverContent = (
127+
<Form form={form} layout="vertical">
128+
<Form.Item
129+
label={t('aiRoute.routeForm.modelMapping.default')}
130+
name="defaultMapping"
131+
>
132+
<Input
133+
allowClear
134+
maxLength={63}
135+
placeholder={t('aiRoute.routeForm.modelMapping.mappingTargetPlaceholder_full') || ''}
136+
/>
137+
</Form.Item>
138+
<Form.Item label={t('aiRoute.routeForm.modelMapping.advanced')}>
139+
<Form.List name="modelMappings" initialValue={[{}]} >
140+
{(fields, { add, remove }) => (
141+
<>
142+
<div className="ant-table ant-table-small">
143+
<div className="ant-table-content">
144+
<table style={{ tableLayout: "auto" }}>
145+
<thead className="ant-table-thead">
146+
<tr>
147+
<th className="ant-table-cell" style={{ width: "150px" }} >{t("aiRoute.routeForm.modelMapping.key")}</th>
148+
<th className="ant-table-cell" style={{ width: "150px" }} >{t("aiRoute.routeForm.modelMatchType")}</th>
149+
<th className="ant-table-cell" style={{ width: "150px" }} >{t("aiRoute.routeForm.modelMapping.target")}</th>
150+
<th className="ant-table-cell" style={{ width: "60px" }} >{t("misc.action")}</th>
151+
</tr>
152+
</thead>
153+
<tbody className="ant-table-tbody">
154+
{
155+
fields.length && fields.map(({ key, name, ...restField }, index) => (
156+
<tr className="ant-table-row ant-table-row-level-0" key={key}>
157+
<td className="ant-table-cell">
158+
<Form.Item
159+
{...restField}
160+
name={[name, 'key']}
161+
noStyle
162+
>
163+
<Input allowClear />
164+
</Form.Item>
165+
</td>
166+
<td className="ant-table-cell">
167+
<Form.Item
168+
name={[name, 'matchType']}
169+
noStyle
170+
>
171+
<Select>
172+
{
173+
[
174+
{ name: MatchType.EQUAL, label: t("route.matchTypes.EQUAL") }, // 精确匹配
175+
{ name: MatchType.PRE, label: t("route.matchTypes.PRE") }, // 前缀匹配
176+
].map((item) => (<Select.Option key={item.name} value={item.name}>{item.label} </Select.Option>))
177+
}
178+
</Select>
179+
</Form.Item>
180+
</td>
181+
<td className="ant-table-cell">
182+
<Form.Item
183+
{...restField}
184+
name={[name, 'target']}
185+
noStyle
186+
>
187+
<Input allowClear placeholder={t('aiRoute.routeForm.modelMapping.mappingTargetPlaceholder_simple') || ''} />
188+
</Form.Item>
189+
</td>
190+
<td className="ant-table-cell">
191+
<MinusCircleOutlined onClick={() => remove(name)} />
192+
</td>
193+
</tr>
194+
)) || (
195+
<tr className="ant-table-row ant-table-row-level-0">
196+
<td className="ant-table-cell" colSpan={4}>
197+
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} style={{ margin: 0 }} />
198+
</td>
199+
</tr>
200+
)
201+
}
202+
</tbody>
203+
</table>
204+
</div>
205+
</div>
206+
<div>
207+
<Button type="dashed" block icon={<PlusOutlined />} onClick={() => add()}>{t("aiRoute.routeForm.modelMapping.add")}</Button>
208+
</div>
209+
</>
210+
)}
211+
</Form.List>
212+
</Form.Item>
213+
<Form.Item
214+
style={{
215+
marginBottom: 0,
216+
textAlign: 'right',
217+
}}
218+
wrapperCol={{ span: 24 }}
219+
>
220+
<Button type="primary" style={{ marginRight: '0.5rem' }} onClick={onConfirm}>
221+
{t('misc.confirm')}
222+
</Button>
223+
<Button onClick={onCancel}>
224+
{t('misc.cancel')}
225+
</Button>
226+
</Form.Item>
227+
</Form>
228+
)
229+
230+
return (
231+
<div style={style}>
232+
<AutoComplete
233+
className="model-mapping-editor"
234+
options={options}
235+
style={{ width: 'calc(100% - 32px)' }}
236+
value={value}
237+
onChange={handleChange}
238+
filterOption={(inputValue, option: any) => {
239+
return option.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
240+
}}
241+
>
242+
<Input />
243+
</AutoComplete>
244+
<Popover
245+
trigger="click"
246+
content={popoverContent}
247+
open={popoverOpen}
248+
onOpenChange={onPopoverOpenChange}
249+
>
250+
<Button icon={<FormOutlined />} />
251+
</Popover>
252+
</div>
253+
);
254+
}
255+
31256
export {
32-
HistoryButton, RedoOutlinedBtn,
257+
HistoryButton,
258+
ModelMappingEditor,
259+
RedoOutlinedBtn,
33260
};

frontend/src/pages/ai/components/RouteForm/index.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import { getConsumers } from '@/services/consumer';
77
import { getLlmProviders } from '@/services/llm-provider';
88
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
99
import { useRequest } from 'ahooks';
10-
import { AutoComplete, Button, Checkbox, Empty, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
10+
import { Button, Checkbox, Empty, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
1111
import { uniqueId } from "lodash";
1212
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
1313
import { useTranslation } from 'react-i18next';
1414
import { aiModelProviders } from '../../configs';
15-
import { HistoryButton, RedoOutlinedBtn } from './Components';
15+
import { HistoryButton, ModelMappingEditor, RedoOutlinedBtn } from './Components';
16+
import { modelMapping2String, string2ModelMapping } from './util';
1617

1718
const { Option } = Select;
1819

@@ -88,7 +89,7 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
8889
}
8990
fallbackInitValues['fallbackConfig_upstreams'] = value?.fallbackConfig?.upstreams?.[0]?.provider;
9091
try {
91-
fallbackInitValues['fallbackConfig_modelNames'] = value?.fallbackConfig?.upstreams?.[0]?.modelMapping['*'];
92+
fallbackInitValues['fallbackConfig_modelNames'] = modelMapping2String(value?.fallbackConfig?.upstreams?.[0]?.modelMapping);
9293
} catch (err) {
9394
fallbackInitValues['fallbackConfig_modelNames'] = '';
9495
}
@@ -123,7 +124,7 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
123124
weight: item.weight,
124125
};
125126
if (item.modelMapping) {
126-
obj["modelMapping"] = item.modelMapping["*"] || null;
127+
obj["modelMapping"] = modelMapping2String(item.modelMapping);
127128
}
128129
return obj;
129130
});
@@ -183,9 +184,7 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
183184
}
184185
payload["upstreams"] = upstreams.map(({ provider, weight, modelMapping }) => {
185186
const obj = { provider, weight, modelMapping: {} };
186-
if (modelMapping) {
187-
obj["modelMapping"]["*"] = modelMapping;
188-
}
187+
obj["modelMapping"] = string2ModelMapping(modelMapping);
189188
return obj;
190189
});
191190
payload["modelPredicates"] = modelPredicates ? modelPredicates.map(({ matchType, matchValue }) => ({ matchType, matchValue })) : null;
@@ -194,7 +193,7 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
194193
provider: fallbackConfig_upstreams,
195194
modelMapping: {},
196195
};
197-
_upstreams["modelMapping"]["*"] = fallbackConfig_modelNames;
196+
_upstreams["modelMapping"] = string2ModelMapping(fallbackConfig_modelNames);
198197
payload['fallbackConfig']['upstreams'] = [_upstreams];
199198
payload['fallbackConfig']['strategy'] = "SEQ";
200199
payload['fallbackConfig']['responseCodes'] = fallbackConfig_responseCodes;
@@ -462,14 +461,10 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
462461
/>
463462
</Form.Item>
464463

465-
<Form.Item {...restField} name={[name, 'modelMapping']} noStyle>{/* 模型名称 */}
466-
<AutoComplete
464+
<Form.Item {...restField} name={[name, 'modelMapping']} noStyle>
465+
<ModelMappingEditor
467466
style={{ ...baseStyle }}
468467
options={getOptions(index)}
469-
filterOption={(inputValue, option: any) => {
470-
return option.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
471-
}}
472-
allowClear
473468
/>
474469
</Form.Item>
475470
{
@@ -545,10 +540,8 @@ const AiRouteForm: React.FC = forwardRef((props: { value: any }, ref) => {
545540
label={t("aiRoute.routeForm.label.targetModel")}
546541
rules={[{ required: true, message: t('aiRoute.routeForm.rule.modelNameRequired') }]}
547542
>{/* 模型名称 */}
548-
<AutoComplete
543+
<ModelMappingEditor
549544
options={getOptionsForAi(form.getFieldValue("fallbackConfig_upstreams"))}
550-
filterOption={(inputValue, option: any) => option.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1}
551-
allowClear
552545
/>
553546
</Form.Item>
554547
</div>

0 commit comments

Comments
 (0)