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) | |||
| } | |||
| 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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 }} | |||
| /> | |||
| <JSONEditor | |||
| <ModelEndpointEditor | |||
| field='endpoints' | |||
| label={t('在模型广场向用户展示的端点')} | |||
| placeholder={ | |||
| @@ -503,56 +515,107 @@ const EditModelModal = (props) => { | |||
| 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 && ( | |||
| <Space wrap> | |||
| {endpointGroups.map((group) => ( | |||
| <Space wrap> | |||
| {Object.entries(ENDPOINT_TEMPLATE).map( | |||
| ([endpointKey, endpointConfig]) => ( | |||
| <Button | |||
| key={group.id} | |||
| key={endpointKey} | |||
| size='small' | |||
| type='primary' | |||
| type='tertiary' | |||
| 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 { | |||
| const current = | |||
| formApiRef.current?.getValue( | |||
| 'endpoints', | |||
| ) || ''; | |||
| let base = {}; | |||
| if (current && current.trim()) | |||
| base = JSON.parse(current); | |||
| 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), | |||
| 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> | |||
| @@ -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'); | |||
| }); | |||