Просмотр исходного кода

feat(models): add structured endpoint editor

Replace freeform endpoint JSON editing with a structured editor, keep advanced JSON mode, and make backend pricing parsing tolerate legacy array-form endpoint configs.

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 1 неделю назад
Родитель
Сommit
3aa4cdf2ec
6 измененных файлов: 740 добавлений и 82 удалений
  1. +65
    -31
      model/pricing.go
  2. +25
    -0
      model/pricing_test.go
  3. +114
    -51
      web/src/components/table/models/modals/EditModelModal.jsx
  4. +272
    -0
      web/src/components/table/models/modals/ModelEndpointEditor.jsx
  5. +190
    -0
      web/src/components/table/models/modals/modelEndpoints.utils.js
  6. +74
    -0
      web/src/components/table/models/modals/modelEndpoints.utils.test.js

+ 65
- 31
model/pricing.go Просмотреть файл

@@ -114,6 +114,48 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
return make([]constant.EndpointType, 0) return make([]constant.EndpointType, 0)
} }


func parseModelEndpointTypes(rawValue string) []string {
trimmed := strings.TrimSpace(rawValue)
if trimmed == "" {
return nil
}

var rawObject map[string]interface{}
if err := common.Unmarshal([]byte(trimmed), &rawObject); err == nil {
endpoints := make([]string, 0, len(rawObject))
for k, v := range rawObject {
switch v.(type) {
case string, map[string]interface{}:
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
}
if len(endpoints) == 0 {
return nil
}
return endpoints
}

var rawList []string
if err := common.Unmarshal([]byte(trimmed), &rawList); err == nil {
endpoints := make([]string, 0, len(rawList))
for _, endpoint := range rawList {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" || common.StringsContains(endpoints, endpoint) {
continue
}
endpoints = append(endpoints, endpoint)
}
if len(endpoints) == 0 {
return nil
}
return endpoints
}

return nil
}

func updatePricing() { func updatePricing() {
enableAbilities, err := GetAllEnableAbilityWithChannels() enableAbilities, err := GetAllEnableAbilityWithChannels()
if err != nil { if err != nil {
@@ -129,9 +171,11 @@ func updatePricing() {
containsList := make([]*Model, 0) containsList := make([]*Model, 0)
for i := range allMeta { for i := range allMeta {
m := &allMeta[i] m := &allMeta[i]
switch m.NameRule {
case NameRuleExact:
if m.NameRule == NameRuleExact {
metaMap[m.ModelName] = m metaMap[m.ModelName] = m
continue
}
switch m.NameRule {
case NameRulePrefix: case NameRulePrefix:
prefixList = append(prefixList, m) prefixList = append(prefixList, m)
case NameRuleSuffix: case NameRuleSuffix:
@@ -142,28 +186,28 @@ func updatePricing() {
} }


for _, m := range prefixList { for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
for _, ability := range enableAbilities {
if strings.HasPrefix(ability.Model, m.ModelName) {
if _, exists := metaMap[ability.Model]; !exists {
metaMap[ability.Model] = m
} }
} }
} }
} }
for _, m := range suffixList { for _, m := range suffixList {
for _, pricingModel := range enableAbilities {
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
for _, ability := range enableAbilities {
if strings.HasSuffix(ability.Model, m.ModelName) {
if _, exists := metaMap[ability.Model]; !exists {
metaMap[ability.Model] = m
} }
} }
} }
} }
for _, m := range containsList { for _, m := range containsList {
for _, pricingModel := range enableAbilities {
if strings.Contains(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
for _, ability := range enableAbilities {
if strings.Contains(ability.Model, m.ModelName) {
if _, exists := metaMap[ability.Model]; !exists {
metaMap[ability.Model] = m
} }
} }
} }
@@ -171,14 +215,14 @@ func updatePricing() {


var vendors []Vendor var vendors []Vendor
_ = DB.Find(&vendors).Error _ = DB.Find(&vendors).Error
vendorMap := make(map[int]*Vendor)
vendorMap := make(map[int]*Vendor, len(vendors))
for i := range vendors { for i := range vendors {
vendorMap[vendors[i].Id] = &vendors[i] vendorMap[vendors[i].Id] = &vendors[i]
} }


initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)


vendorOrderMap := make(map[int]int)
vendorOrderMap := make(map[int]int, len(vendorMap))
for _, v := range vendorMap { for _, v := range vendorMap {
vendorOrderMap[v.Id] = v.SortOrder vendorOrderMap[v.Id] = v.SortOrder
} }
@@ -227,26 +271,14 @@ func updatePricing() {
if strings.TrimSpace(meta.Endpoints) == "" { if strings.TrimSpace(meta.Endpoints) == "" {
continue continue
} }
var raw map[string]interface{}
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := make([]string, 0, len(raw))
for k, v := range raw {
switch v.(type) {
case string, map[string]interface{}:
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
}
if len(endpoints) > 0 {
modelSupportEndpointsStr[modelName] = endpoints
}
if endpoints := parseModelEndpointTypes(meta.Endpoints); len(endpoints) > 0 {
modelSupportEndpointsStr[modelName] = endpoints
} }
} }


modelSupportEndpointTypes = make(map[string][]constant.EndpointType) modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr { for model, endpoints := range modelSupportEndpointsStr {
supportedEndpoints := make([]constant.EndpointType, 0)
supportedEndpoints := make([]constant.EndpointType, 0, len(endpoints))
for _, endpointStr := range endpoints { for _, endpointStr := range endpoints {
supportedEndpoints = append(supportedEndpoints, constant.EndpointType(endpointStr)) supportedEndpoints = append(supportedEndpoints, constant.EndpointType(endpointStr))
} }
@@ -294,6 +326,7 @@ func updatePricing() {
EnableGroup: groups.Items(), EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model], SupportedEndpointTypes: modelSupportEndpointTypes[model],
} }

if meta, ok := metaMap[model]; ok { if meta, ok := metaMap[model]; ok {
if meta.Status == 0 { if meta.Status == 0 {
continue continue
@@ -304,6 +337,7 @@ func updatePricing() {
pricing.VendorID = meta.VendorID pricing.VendorID = meta.VendorID
pricing.Type = meta.Type pricing.Type = meta.Type
} }

applyGlobalDefault(&pricing, model) applyGlobalDefault(&pricing, model)
pricingMap = append(pricingMap, pricing) pricingMap = append(pricingMap, pricing)
} }


+ 25
- 0
model/pricing_test.go Просмотреть файл

@@ -73,6 +73,8 @@ func setupPricingTest(t *testing.T) {


func TestApplyGlobalDefault_PerTokenModel(t *testing.T) { func TestApplyGlobalDefault_PerTokenModel(t *testing.T) {
resetPricingRatios(t) resetPricingRatios(t)
t.Cleanup(func() { resetPricingRatios(t) })

require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingApplyModel+`":10}`)) require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingApplyModel+`":10}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingApplyModel+`":3}`)) require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingApplyModel+`":3}`))
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingApplyModel+`":0.5}`)) require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingApplyModel+`":0.5}`))
@@ -90,6 +92,8 @@ func TestApplyGlobalDefault_PerTokenModel(t *testing.T) {


func TestApplyGlobalDefault_PerCallModel(t *testing.T) { func TestApplyGlobalDefault_PerCallModel(t *testing.T) {
resetPricingRatios(t) resetPricingRatios(t)
t.Cleanup(func() { resetPricingRatios(t) })

require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testPricingCallModel+`":0.3}`)) require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testPricingCallModel+`":0.3}`))


p := &Pricing{} p := &Pricing{}
@@ -137,3 +141,24 @@ func TestGetPricingByModel_UsesGlobalDefaultOnly(t *testing.T) {
require.Equal(t, 0.5, pricing.CacheRatio) require.Equal(t, 0.5, pricing.CacheRatio)
require.Equal(t, 0.75, pricing.CacheCreationRatio) require.Equal(t, 0.75, pricing.CacheCreationRatio)
} }

func TestParseModelEndpointTypes_SupportsObjectAndArray(t *testing.T) {
objectEndpoints := parseModelEndpointTypes(`{
"anthropic": {"path": "/v1/messages", "method": "POST"},
"openai": "/v1/chat/completions"
}`)
require.ElementsMatch(t, []string{"anthropic", "openai"}, objectEndpoints)

arrayEndpoints := parseModelEndpointTypes(`["anthropic", "openai", "anthropic"]`)
require.Equal(t, []string{"anthropic", "openai"}, arrayEndpoints)
}

func TestParseModelEndpointTypes_IgnoresInvalidJSON(t *testing.T) {
require.Nil(t, parseModelEndpointTypes(`123`))
require.Nil(t, parseModelEndpointTypes(`{"anthropic": true}`))
require.Nil(t, parseModelEndpointTypes(``))

info, ok := common.GetDefaultEndpointInfo("anthropic")
require.True(t, ok)
require.Equal(t, "/v1/messages", info.Path)
}

+ 114
- 51
web/src/components/table/models/modals/EditModelModal.jsx Просмотреть файл

@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */


import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
import JSONEditor from '../../../common/ui/JSONEditor';
import ModelEndpointEditor from './ModelEndpointEditor';
import { import {
Banner, Banner,
SideSheet, SideSheet,
@@ -39,20 +39,14 @@ import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import { MODEL_TYPE_OPTIONS } from '../../../../constants/common.constant'; import { MODEL_TYPE_OPTIONS } from '../../../../constants/common.constant';
import {
ENDPOINT_TEMPLATE,
normalizeEndpointSubmitValue,
parseEndpointConfig,
} from './modelEndpoints.utils';


const { Text, Title } = Typography; const { Text, Title } = Typography;


// Example endpoint template for quick fill
const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
anthropic: { path: '/v1/messages', method: 'POST' },
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
};

const nameRuleOptions = [ const nameRuleOptions = [
{ label: '精确名称匹配', value: 0 }, { label: '精确名称匹配', value: 0 },
{ label: '前缀名称匹配', value: 1 }, { label: '前缀名称匹配', value: 1 },
@@ -63,6 +57,7 @@ const nameRuleOptions = [
const EditModelModal = (props) => { const EditModelModal = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [endpointValidationError, setEndpointValidationError] = useState('');
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const formApiRef = useRef(null); const formApiRef = useRef(null);
const isEdit = props.editingModel && props.editingModel.id !== undefined; const isEdit = props.editingModel && props.editingModel.id !== undefined;
@@ -130,6 +125,7 @@ const EditModelModal = (props) => {
}); });


const handleCancel = () => { const handleCancel = () => {
setEndpointValidationError('');
props.handleClose(); props.handleClose();
}; };


@@ -186,19 +182,35 @@ const EditModelModal = (props) => {
...getInitValues(), ...getInitValues(),
model_name: props.editingModel?.model_name || '', model_name: props.editingModel?.model_name || '',
}); });
setEndpointValidationError('');
} }
} else { } else {
formApiRef.current?.reset(); formApiRef.current?.reset();
setEndpointValidationError('');
} }
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]); }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);


const submit = async (values) => { const submit = async (values) => {
if (endpointValidationError) {
showError(endpointValidationError);
return;
}

const normalizedEndpoints = normalizeEndpointSubmitValue(
values.endpoints,
parseEndpointConfig(values.endpoints).rows,
);
if (normalizedEndpoints.error) {
showError(normalizedEndpoints.error);
return;
}

setLoading(true); setLoading(true);
try { try {
const submitData = { const submitData = {
...values, ...values,
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
endpoints: values.endpoints || '',
endpoints: normalizedEndpoints.value,
status: values.status ? 1 : 0, status: values.status ? 1 : 0,
sync_official: values.sync_official ? 1 : 0, sync_official: values.sync_official ? 1 : 0,
}; };
@@ -493,7 +505,7 @@ const EditModelModal = (props) => {
)} )}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
/> />
<JSONEditor
<ModelEndpointEditor
field='endpoints' field='endpoints'
label={t('在模型广场向用户展示的端点')} label={t('在模型广场向用户展示的端点')}
placeholder={ placeholder={
@@ -503,56 +515,107 @@ const EditModelModal = (props) => {
onChange={(val) => onChange={(val) =>
formApiRef.current?.setValue('endpoints', val) formApiRef.current?.setValue('endpoints', val)
} }
onValidationChange={setEndpointValidationError}
formApi={formApiRef.current} formApi={formApiRef.current}
editorType='object'
template={ENDPOINT_TEMPLATE}
templateLabel={t('填入模板')}
extraText={t('留空则使用默认端点;支持 {path, method}')}
extraText={t(
'推荐直接填写端点类型、路径和方法;高级模式下也只接受 JSON 对象',
)}
extraFooter={ extraFooter={
endpointGroups.length > 0 && (
<Space wrap>
{endpointGroups.map((group) => (
<Space wrap>
{Object.entries(ENDPOINT_TEMPLATE).map(
([endpointKey, endpointConfig]) => (
<Button <Button
key={group.id}
key={endpointKey}
size='small' size='small'
type='primary'
type='tertiary'
onClick={() => { onClick={() => {
const parsed = parseEndpointConfig(
formApiRef.current?.getValue('endpoints') ||
'',
);
const nextRows = [...parsed.rows];
const existingIndex = nextRows.findIndex(
(row) => row.key === endpointKey,
);
const nextRow = {
key: endpointKey,
path: endpointConfig.path,
method: endpointConfig.method,
};

if (existingIndex >= 0) {
nextRows[existingIndex] = nextRow;
} else {
nextRows.push(nextRow);
}

const normalized =
normalizeEndpointSubmitValue('', nextRows);
if (normalized.error) {
showError(normalized.error);
return;
}
formApiRef.current?.setValue(
'endpoints',
normalized.value,
);
}}
>
{endpointKey}
</Button>
),
)}
{endpointGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
try {
const parsed = parseEndpointConfig(
formApiRef.current?.getValue('endpoints') ||
'',
);
let base = {};
if (parsed.rows.length > 0) {
const normalized =
normalizeEndpointSubmitValue(
'',
parsed.rows,
);
if (normalized.error) {
showError(normalized.error);
return;
}
base = JSON.parse(normalized.value || '{}');
}
const groupObj =
typeof group.items === 'string'
? JSON.parse(group.items || '{}')
: group.items || {};
const merged = { ...base, ...groupObj };
formApiRef.current?.setValue(
'endpoints',
JSON.stringify(merged, null, 2),
);
} catch (e) {
try { try {
const current =
formApiRef.current?.getValue(
'endpoints',
) || '';
let base = {};
if (current && current.trim())
base = JSON.parse(current);
const groupObj = const groupObj =
typeof group.items === 'string' typeof group.items === 'string'
? JSON.parse(group.items || '{}') ? JSON.parse(group.items || '{}')
: group.items || {}; : group.items || {};
const merged = { ...base, ...groupObj };
formApiRef.current?.setValue( formApiRef.current?.setValue(
'endpoints', 'endpoints',
JSON.stringify(merged, null, 2),
JSON.stringify(groupObj, null, 2),
); );
} catch (e) {
try {
const groupObj =
typeof group.items === 'string'
? JSON.parse(group.items || '{}')
: group.items || {};
formApiRef.current?.setValue(
'endpoints',
JSON.stringify(groupObj, null, 2),
);
} catch {}
}
}}
>
{group.name}
</Button>
))}
</Space>
)
} catch {}
}
}}
>
{group.name}
</Button>
))}
</Space>
} }
/> />
</Col> </Col>


+ 272
- 0
web/src/components/table/models/modals/ModelEndpointEditor.jsx Просмотреть файл

@@ -0,0 +1,272 @@
/*
Copyright (C) 2025 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Banner,
Button,
Card,
Col,
Divider,
Form,
Input,
Row,
Select,
Tabs,
TabPane,
TextArea,
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import { IconAlertTriangle, IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import {
createEndpointRow,
ENDPOINT_METHOD_OPTIONS,
ENDPOINT_TEMPLATE,
ENDPOINT_TYPE_OPTIONS,
parseEndpointConfig,
stringifyEndpointConfig,
validateEndpointRows,
} from './modelEndpoints.utils';

const { Text } = Typography;

const ModelEndpointEditor = ({
value = '',
field,
label,
placeholder,
extraText,
extraFooter,
onChange,
onValidationChange,
formApi,
}) => {
const { t } = useTranslation();
const [editMode, setEditMode] = useState('visual');
const [rows, setRows] = useState([]);
const [manualText, setManualText] = useState('');
const [manualError, setManualError] = useState('');

useEffect(() => {
const parsed = parseEndpointConfig(value);
setRows(parsed.rows);
setManualText(parsed.jsonText);
setManualError(parsed.error);
onValidationChange?.(parsed.error);
}, [value, onValidationChange]);

const rowError = useMemo(() => validateEndpointRows(rows), [rows]);
const activeError = manualError || rowError;

const syncValue = useCallback(
(nextRows) => {
const rowValidationError = validateEndpointRows(nextRows);
const normalized = stringifyEndpointConfig(nextRows);
setRows(nextRows);
setManualText(normalized);
setManualError('');
onValidationChange?.(rowValidationError);
if (!rowValidationError) {
formApi?.setValue(field, normalized);
onChange?.(normalized);
}
},
[field, formApi, onChange, onValidationChange],
);

const updateRow = (index, patch) => {
const nextRows = rows.map((row, rowIndex) => {
if (rowIndex !== index) return row;
if (Object.prototype.hasOwnProperty.call(patch, 'key')) {
const nextKey = patch.key || '';
return {
...row,
...patch,
path:
row.path ||
(nextKey ? ENDPOINT_TEMPLATE[nextKey]?.path || '' : ''),
method: row.method || 'POST',
};
}
return { ...row, ...patch };
});
syncValue(nextRows);
};

const updateEndpointType = (index, nextValue) => {
const endpointType =
typeof nextValue === 'string' ? nextValue.trim() : nextValue;
updateRow(index, { key: endpointType || '' });
};

const addRow = () => {
syncValue([...rows, createEndpointRow()]);
};

const removeRow = (index) => {
syncValue(rows.filter((_, rowIndex) => rowIndex !== index));
};

const handleManualChange = (nextValue) => {
setManualText(nextValue);
if (!nextValue.trim()) {
setRows([]);
setManualError('');
onValidationChange?.('');
formApi?.setValue(field, '');
onChange?.('');
return;
}

const parsed = parseEndpointConfig(nextValue);
setManualError(parsed.error);
onValidationChange?.(parsed.error);
if (parsed.error) {
return;
}

const validationError = validateEndpointRows(parsed.rows);
setRows(parsed.rows);
setManualText(parsed.jsonText);
onValidationChange?.(validationError);
if (!validationError) {
formApi?.setValue(field, parsed.jsonText);
onChange?.(parsed.jsonText);
}
};

return (
<Form.Slot label={label}>
<Card
header={
<Tabs type='slash' activeKey={editMode} onChange={setEditMode}>
<TabPane tab={t('结构化编辑')} itemKey='visual' />
<TabPane tab={t('高级 JSON')} itemKey='manual' />
</Tabs>
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className='!rounded-2xl'
>
{activeError && (
<Banner
type='danger'
icon={<IconAlertTriangle />}
description={activeError}
className='mb-3'
/>
)}

{editMode === 'visual' ? (
<div className='space-y-2'>
{rows.length === 0 && (
<div className='text-center py-6 px-4'>
<Text type='tertiary' size='small'>
{t('暂无端点配置,点击下方按钮新增')}
</Text>
</div>
)}
{rows.map((row, index) => (
<Row key={`${row.key || 'endpoint'}-${index}`} gutter={8} className='mb-2'>
<Col span={8}>
<Select
placeholder={t('端点类型')}
value={row.key}
optionList={ENDPOINT_TYPE_OPTIONS.map((endpointType) => ({
label: endpointType,
value: endpointType,
}))}
filter
allowCreate
searchPlaceholder={t('选择或输入自定义端点类型')}
renderCreateItem={(inputValue) =>
inputValue
? `${t('创建自定义端点类型')}: ${inputValue}`
: null
}
onCreate={(option) =>
updateEndpointType(index, option?.value || option)
}
onChange={(nextValue) => updateEndpointType(index, nextValue)}
style={{ width: '100%' }}
/>
</Col>
<Col span={11}>
<Input
placeholder={t('请求路径')}
value={row.path}
onChange={(nextValue) => updateRow(index, { path: nextValue })}
/>
</Col>
<Col span={3}>
<Select
value={row.method || 'POST'}
optionList={ENDPOINT_METHOD_OPTIONS.map((method) => ({
label: method,
value: method,
}))}
onChange={(nextValue) => updateRow(index, { method: nextValue })}
style={{ width: '100%' }}
/>
</Col>
<Col span={2}>
<Tooltip content={t('删除端点')}>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
onClick={() => removeRow(index)}
style={{ width: '100%' }}
/>
</Tooltip>
</Col>
</Row>
))}
<div className='flex justify-center'>
<Button icon={<IconPlus />} theme='outline' type='primary' onClick={addRow}>
{t('新增端点')}
</Button>
</div>
</div>
) : (
<TextArea
placeholder={placeholder}
value={manualText}
onChange={handleManualChange}
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
/>
)}

<Form.Input field={field} value={value} style={{ display: 'none' }} noLabel />

<Divider margin='12px' align='center'>
<Text type='tertiary' size='small'>
{extraText}
</Text>
</Divider>

{extraFooter && <div className='mt-2'>{extraFooter}</div>}
</Card>
</Form.Slot>
);
};

export default ModelEndpointEditor;

+ 190
- 0
web/src/components/table/models/modals/modelEndpoints.utils.js Просмотреть файл

@@ -0,0 +1,190 @@
/*
Copyright (C) 2025 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/

export const ENDPOINT_METHOD_OPTIONS = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'];

export const ENDPOINT_TYPE_OPTIONS = [
'openai',
'openai-response',
'openai-response-compact',
'anthropic',
'gemini',
'jina-rerank',
'image-generation',
'embeddings',
'openai-video',
];

export const ENDPOINT_TEMPLATE = {
openai: { path: '/v1/chat/completions', method: 'POST' },
'openai-response': { path: '/v1/responses', method: 'POST' },
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
anthropic: { path: '/v1/messages', method: 'POST' },
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
'image-generation': { path: '/v1/images/generations', method: 'POST' },
embeddings: { path: '/v1/embeddings', method: 'POST' },
'openai-video': { path: '/v1/video/generations', method: 'POST' },
};

const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);

export const createEndpointRow = (partial = {}) => ({
key: partial.key || '',
path:
partial.path ||
(partial.key && ENDPOINT_TEMPLATE[partial.key]?.path) ||
'',
method: (partial.method || 'POST').toUpperCase(),
});

export const parseEndpointConfig = (rawValue) => {
const trimmed = typeof rawValue === 'string' ? rawValue.trim() : '';
if (!trimmed) {
return { rows: [], jsonText: '', error: '' };
}

let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
return { rows: [], jsonText: rawValue, error: error.message };
}

if (!isPlainObject(parsed)) {
return {
rows: [],
jsonText: rawValue,
error: '端点配置必须是 JSON 对象',
};
}

const rows = Object.entries(parsed).map(([key, value]) => {
if (typeof value === 'string') {
return createEndpointRow({ key, path: value, method: 'POST' });
}
if (isPlainObject(value)) {
return createEndpointRow({
key,
path: typeof value.path === 'string' ? value.path : '',
method: typeof value.method === 'string' ? value.method : 'POST',
});
}
return createEndpointRow({ key });
});

return {
rows,
jsonText: JSON.stringify(buildEndpointConfig(rows), null, 2),
error: '',
};
};

export const buildEndpointConfig = (rows) => {
const result = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
const key = typeof row?.key === 'string' ? row.key.trim() : '';
const path = typeof row?.path === 'string' ? row.path.trim() : '';
if (!key || !path) {
return;
}
result[key] = {
path,
method:
typeof row?.method === 'string' && row.method.trim()
? row.method.trim().toUpperCase()
: 'POST',
};
});
return result;
};

export const stringifyEndpointConfig = (rows) => {
const config = buildEndpointConfig(rows);
if (Object.keys(config).length === 0) {
return '';
}
return JSON.stringify(config, null, 2);
};

export const validateEndpointRows = (rows) => {
const normalizedRows = Array.isArray(rows) ? rows : [];
const duplicateKeys = new Set();
const keyCount = new Map();

normalizedRows.forEach((row) => {
const key = typeof row?.key === 'string' ? row.key.trim() : '';
if (!key) {
return;
}
const count = (keyCount.get(key) || 0) + 1;
keyCount.set(key, count);
if (count > 1) {
duplicateKeys.add(key);
}
});

if (duplicateKeys.size > 0) {
return `端点类型不能重复:${Array.from(duplicateKeys).join(', ')}`;
}

for (const row of normalizedRows) {
const key = typeof row?.key === 'string' ? row.key.trim() : '';
const path = typeof row?.path === 'string' ? row.path.trim() : '';
if (!key && !path) {
continue;
}
if (!key) {
return '端点类型不能为空';
}
if (!path) {
return `端点 ${key} 的路径不能为空`;
}
}

return '';
};

export const normalizeEndpointSubmitValue = (rawValue, rows) => {
const rowError = validateEndpointRows(rows);
if (rowError) {
return { value: '', error: rowError };
}

const normalized = stringifyEndpointConfig(rows);
if (normalized) {
return { value: normalized, error: '' };
}

const trimmed = typeof rawValue === 'string' ? rawValue.trim() : '';
if (!trimmed) {
return { value: '', error: '' };
}

const parsed = parseEndpointConfig(rawValue);
if (parsed.error) {
return { value: '', error: parsed.error };
}

return {
value: stringifyEndpointConfig(parsed.rows),
error: '',
};
};

+ 74
- 0
web/src/components/table/models/modals/modelEndpoints.utils.test.js Просмотреть файл

@@ -0,0 +1,74 @@
/*
Copyright (C) 2025 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/

import test from 'node:test';
import assert from 'node:assert/strict';

import {
createEndpointRow,
normalizeEndpointSubmitValue,
parseEndpointConfig,
stringifyEndpointConfig,
} from './modelEndpoints.utils';

test('parseEndpointConfig rejects top-level array input', () => {
const result = parseEndpointConfig('["anthropic"]');

assert.equal(result.error, '端点配置必须是 JSON 对象');
assert.deepEqual(result.rows, []);
});

test('stringifyEndpointConfig builds endpoint object from structured rows', () => {
const result = stringifyEndpointConfig([
{ key: 'anthropic', path: '/v1/messages', method: 'post' },
]);

assert.equal(
result,
JSON.stringify(
{
anthropic: {
path: '/v1/messages',
method: 'POST',
},
},
null,
2,
),
);
});

test('createEndpointRow fills default path from endpoint type', () => {
const result = createEndpointRow({ key: 'anthropic' });

assert.deepEqual(result, {
key: 'anthropic',
path: '/v1/messages',
method: 'POST',
});
});

test('normalizeEndpointSubmitValue rejects duplicate endpoint keys', () => {
const result = normalizeEndpointSubmitValue('', [
{ key: 'anthropic', path: '/v1/messages', method: 'POST' },
{ key: 'anthropic', path: '/v1/messages', method: 'POST' },
]);

assert.equal(result.error, '端点类型不能重复:anthropic');
});

Загрузка…
Отмена
Сохранить