From 3aa4cdf2ec54a138f11ac719e6ea55bbe04ed7c3 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Mon, 1 Jun 2026 16:24:06 +0800 Subject: [PATCH] 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 --- model/pricing.go | 96 +++++-- model/pricing_test.go | 25 ++ .../table/models/modals/EditModelModal.jsx | 165 +++++++---- .../models/modals/ModelEndpointEditor.jsx | 272 ++++++++++++++++++ .../models/modals/modelEndpoints.utils.js | 190 ++++++++++++ .../modals/modelEndpoints.utils.test.js | 74 +++++ 6 files changed, 740 insertions(+), 82 deletions(-) create mode 100644 web/src/components/table/models/modals/ModelEndpointEditor.jsx create mode 100644 web/src/components/table/models/modals/modelEndpoints.utils.js create mode 100644 web/src/components/table/models/modals/modelEndpoints.utils.test.js diff --git a/model/pricing.go b/model/pricing.go index ee80bff..d07839b 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -114,6 +114,48 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType { 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() { enableAbilities, err := GetAllEnableAbilityWithChannels() if err != nil { @@ -129,9 +171,11 @@ func updatePricing() { containsList := make([]*Model, 0) for i := range allMeta { m := &allMeta[i] - switch m.NameRule { - case NameRuleExact: + if m.NameRule == NameRuleExact { metaMap[m.ModelName] = m + continue + } + switch m.NameRule { case NameRulePrefix: prefixList = append(prefixList, m) case NameRuleSuffix: @@ -142,28 +186,28 @@ func updatePricing() { } 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 _, 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 _, 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 _ = DB.Find(&vendors).Error - vendorMap := make(map[int]*Vendor) + vendorMap := make(map[int]*Vendor, len(vendors)) for i := range vendors { vendorMap[vendors[i].Id] = &vendors[i] } initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) - vendorOrderMap := make(map[int]int) + vendorOrderMap := make(map[int]int, len(vendorMap)) for _, v := range vendorMap { vendorOrderMap[v.Id] = v.SortOrder } @@ -227,26 +271,14 @@ func updatePricing() { if strings.TrimSpace(meta.Endpoints) == "" { 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) for model, endpoints := range modelSupportEndpointsStr { - supportedEndpoints := make([]constant.EndpointType, 0) + supportedEndpoints := make([]constant.EndpointType, 0, len(endpoints)) for _, endpointStr := range endpoints { supportedEndpoints = append(supportedEndpoints, constant.EndpointType(endpointStr)) } @@ -294,6 +326,7 @@ func updatePricing() { EnableGroup: groups.Items(), SupportedEndpointTypes: modelSupportEndpointTypes[model], } + if meta, ok := metaMap[model]; ok { if meta.Status == 0 { continue @@ -304,6 +337,7 @@ func updatePricing() { pricing.VendorID = meta.VendorID pricing.Type = meta.Type } + applyGlobalDefault(&pricing, model) pricingMap = append(pricingMap, pricing) } diff --git a/model/pricing_test.go b/model/pricing_test.go index 84415c3..ca59a13 100644 --- a/model/pricing_test.go +++ b/model/pricing_test.go @@ -73,6 +73,8 @@ func setupPricingTest(t *testing.T) { func TestApplyGlobalDefault_PerTokenModel(t *testing.T) { resetPricingRatios(t) + t.Cleanup(func() { resetPricingRatios(t) }) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingApplyModel+`":10}`)) require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingApplyModel+`":3}`)) 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) { resetPricingRatios(t) + t.Cleanup(func() { resetPricingRatios(t) }) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testPricingCallModel+`":0.3}`)) p := &Pricing{} @@ -137,3 +141,24 @@ func TestGetPricingByModel_UsesGlobalDefaultOnly(t *testing.T) { require.Equal(t, 0.5, pricing.CacheRatio) 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) +} diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 84610db..fb4619c 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/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 JSONEditor from '../../../common/ui/JSONEditor'; +import ModelEndpointEditor from './ModelEndpointEditor'; import { Banner, SideSheet, @@ -39,20 +39,14 @@ import { API, showError, showSuccess } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { MODEL_TYPE_OPTIONS } from '../../../../constants/common.constant'; +import { + ENDPOINT_TEMPLATE, + normalizeEndpointSubmitValue, + parseEndpointConfig, +} from './modelEndpoints.utils'; 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 = [ { label: '精确名称匹配', value: 0 }, { label: '前缀名称匹配', value: 1 }, @@ -63,6 +57,7 @@ const nameRuleOptions = [ const EditModelModal = (props) => { const { t } = useTranslation(); const [loading, setLoading] = useState(false); + const [endpointValidationError, setEndpointValidationError] = useState(''); const isMobile = useIsMobile(); const formApiRef = useRef(null); const isEdit = props.editingModel && props.editingModel.id !== undefined; @@ -130,6 +125,7 @@ const EditModelModal = (props) => { }); const handleCancel = () => { + setEndpointValidationError(''); props.handleClose(); }; @@ -186,19 +182,35 @@ const EditModelModal = (props) => { ...getInitValues(), model_name: props.editingModel?.model_name || '', }); + setEndpointValidationError(''); } } else { formApiRef.current?.reset(); + setEndpointValidationError(''); } }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]); 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); try { const submitData = { ...values, tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags, - endpoints: values.endpoints || '', + endpoints: normalizedEndpoints.value, status: values.status ? 1 : 0, sync_official: values.sync_official ? 1 : 0, }; @@ -493,7 +505,7 @@ const EditModelModal = (props) => { )} style={{ marginBottom: 12 }} /> - { onChange={(val) => formApiRef.current?.setValue('endpoints', val) } + onValidationChange={setEndpointValidationError} formApi={formApiRef.current} - editorType='object' - template={ENDPOINT_TEMPLATE} - templateLabel={t('填入模板')} - extraText={t('留空则使用默认端点;支持 {path, method}')} + extraText={t( + '推荐直接填写端点类型、路径和方法;高级模式下也只接受 JSON 对象', + )} extraFooter={ - endpointGroups.length > 0 && ( - - {endpointGroups.map((group) => ( + + {Object.entries(ENDPOINT_TEMPLATE).map( + ([endpointKey, endpointConfig]) => ( + ), + )} + {endpointGroups.map((group) => ( + - ))} - - ) + } catch {} + } + }} + > + {group.name} + + ))} + } /> diff --git a/web/src/components/table/models/modals/ModelEndpointEditor.jsx b/web/src/components/table/models/modals/ModelEndpointEditor.jsx new file mode 100644 index 0000000..35aa21f --- /dev/null +++ b/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 . + +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 ( + + + + + + } + headerStyle={{ padding: '12px 16px' }} + bodyStyle={{ padding: '16px' }} + className='!rounded-2xl' + > + {activeError && ( + } + description={activeError} + className='mb-3' + /> + )} + + {editMode === 'visual' ? ( +
+ {rows.length === 0 && ( +
+ + {t('暂无端点配置,点击下方按钮新增')} + +
+ )} + {rows.map((row, index) => ( + + + updateRow(index, { path: nextValue })} + /> + + +