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
| @@ -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) | ||||
| } | } | ||||
| @@ -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) | |||||
| } | |||||
| @@ -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> | ||||
| @@ -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; | |||||
| @@ -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: '', | |||||
| }; | |||||
| }; | |||||
| @@ -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'); | |||||
| }); | |||||