From 8fba0017c798452617e33f6b46b84e29fb4a9d41 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 8 Aug 2025 02:34:15 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(pricing+endpoints+ui):=20wire?= =?UTF-8?q?=20custom=20endpoint=20mapping=20end=E2=80=91to=E2=80=91end=20a?= =?UTF-8?q?nd=20overhaul=20visual=20JSON=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (Go) - Include custom endpoints in each model’s SupportedEndpointTypes by parsing Model.Endpoints (JSON) and appending keys alongside native endpoint types. - Build a global supportedEndpointMap map[string]EndpointInfo{path, method} by: - Seeding with native defaults. - Overriding/adding from models.endpoints (accepts string path → default POST, or {path, method}). - Expose supported_endpoint at the top level of /api/pricing (vendors-like), removing per-model duplication. - Fix default path for EndpointTypeOpenAIResponse to /v1/responses. - Keep concurrency/caching for pricing retrieval intact. Frontend (React) - Fetch supported_endpoint in useModelPricingData and propagate to PricingPage → ModelDetailSideSheet → ModelEndpoints. - ModelEndpoints - Resolve path+method via endpointMap; replace {model} with actual model name. - Fix mobile visibility; always show path and HTTP method. - JSONEditor - Wrap with Form.Slot to inherit form layout; simplify visual styles. - Use Tabs for “Visual” / “Manual” modes. - Unify editors: key-value editor now supports nested JSON: - “+” to convert a primitive into an object and add nested fields. - Add “Convert to value” for two‑way toggle back from object. - Stable key rename without reordering rows; new rows append at bottom. - Use Row/Col grid for clean alignment; region editor uses Form.Slot + grid. - Editing flows - EditModelModal / EditPrefillGroupModal use JSONEditor (editorType='object') for endpoint mappings. - PrefillGroupManagement renders endpoint group items by JSON keys. Data expectations / compatibility - models.endpoints should be a JSON object mapping endpoint type → string path or {path, method}. Strings default to POST. - No schema changes; existing TEXT field continues to store JSON. QA - /api/pricing now returns custom endpoint types and global supported_endpoint. - UI shows both native and custom endpoints; paths/methods render on mobile; nested editing works and preserves order. --- common/endpoint_defaults.go | 32 + controller/pricing.go | 7 +- model/pricing.go | 139 +++- web/src/components/common/ui/JSONEditor.js | 698 +++++++++--------- .../model-pricing/layout/PricingPage.jsx | 1 + .../modal/ModelDetailSideSheet.jsx | 3 +- .../modal/components/ModelEndpoints.jsx | 58 +- .../table/models/modals/EditModelModal.jsx | 54 +- .../models/modals/EditPrefillGroupModal.jsx | 59 +- .../models/modals/PrefillGroupManagement.jsx | 16 +- .../model-pricing/useModelPricingData.js | 5 +- 11 files changed, 614 insertions(+), 458 deletions(-) create mode 100644 common/endpoint_defaults.go diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go new file mode 100644 index 00000000..1dfe1dc9 --- /dev/null +++ b/common/endpoint_defaults.go @@ -0,0 +1,32 @@ +package common + +import "one-api/constant" + +// EndpointInfo 描述单个端点的默认请求信息 +// path: 上游路径 +// method: HTTP 请求方式,例如 POST/GET +// 目前均为 POST,后续可扩展 +// +// json 标签用于直接序列化到 API 输出 +// 例如:{"path":"/v1/chat/completions","method":"POST"} + +type EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` +} + +// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method +var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ + constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, + constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, + constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, + constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"}, + constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, +} + +// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 +func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { + info, ok := defaultEndpointInfoMap[et] + return info, ok +} diff --git a/controller/pricing.go b/controller/pricing.go index 7205cb03..e1719cf3 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -42,9 +42,10 @@ func GetPricing(c *gin.Context) { "success": true, "data": pricing, "vendors": model.GetVendors(), - "group_ratio": groupRatio, - "usable_group": usableGroup, - }) + "group_ratio": groupRatio, + "usable_group": usableGroup, + "supported_endpoint": model.GetSupportedEndpointMap(), + }) } func ResetModelRatio(c *gin.Context) { diff --git a/model/pricing.go b/model/pricing.go index 1eaf8c16..2b3920ba 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -1,28 +1,30 @@ package model import ( - "fmt" - "strings" - "one-api/common" - "one-api/constant" - "one-api/setting/ratio_setting" - "one-api/types" - "sync" - "time" + "encoding/json" + "fmt" + "strings" + + "one-api/common" + "one-api/constant" + "one-api/setting/ratio_setting" + "one-api/types" + "sync" + "time" ) type Pricing struct { - ModelName string `json:"model_name"` - Description string `json:"description,omitempty"` - Tags string `json:"tags,omitempty"` - VendorID int `json:"vendor_id,omitempty"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - ModelPrice float64 `json:"model_price"` - OwnerBy string `json:"owner_by"` - CompletionRatio float64 `json:"completion_ratio"` - EnableGroup []string `json:"enable_groups"` - SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` } type PricingVendor struct { @@ -33,10 +35,11 @@ type PricingVendor struct { } var ( - pricingMap []Pricing - vendorsList []PricingVendor - lastGetPricingTime time.Time - updatePricingLock sync.Mutex + pricingMap []Pricing + vendorsList []PricingVendor + supportedEndpointMap map[string]common.EndpointInfo + lastGetPricingTime time.Time + updatePricingLock sync.Mutex // 缓存映射:模型名 -> 启用分组 / 计费类型 modelEnableGroups = make(map[string][]string) @@ -176,20 +179,34 @@ func updatePricing() { //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 modelSupportEndpointsStr := make(map[string][]string) - for _, ability := range enableAbilities { - endpoints, ok := modelSupportEndpointsStr[ability.Model] - if !ok { - endpoints = make([]string, 0) - modelSupportEndpointsStr[ability.Model] = endpoints - } - channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) - for _, channelType := range channelTypes { - if !common.StringsContains(endpoints, string(channelType)) { - endpoints = append(endpoints, string(channelType)) - } - } - modelSupportEndpointsStr[ability.Model] = endpoints - } + // 先根据已有能力填充原生端点 + for _, ability := range enableAbilities { + endpoints := modelSupportEndpointsStr[ability.Model] + channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + for _, channelType := range channelTypes { + if !common.StringsContains(endpoints, string(channelType)) { + endpoints = append(endpoints, string(channelType)) + } + } + modelSupportEndpointsStr[ability.Model] = endpoints + } + + // 再补充模型自定义端点 + for modelName, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + endpoints := modelSupportEndpointsStr[modelName] + for k := range raw { + if !common.StringsContains(endpoints, k) { + endpoints = append(endpoints, k) + } + } + modelSupportEndpointsStr[modelName] = endpoints + } + } modelSupportEndpointTypes = make(map[string][]constant.EndpointType) for model, endpoints := range modelSupportEndpointsStr { @@ -199,9 +216,48 @@ func updatePricing() { supportedEndpoints = append(supportedEndpoints, endpointType) } modelSupportEndpointTypes[model] = supportedEndpoints - } + } - pricingMap = make([]Pricing, 0) + // 构建全局 supportedEndpointMap(默认 + 自定义覆盖) + supportedEndpointMap = make(map[string]common.EndpointInfo) + // 1. 默认端点 + for _, endpoints := range modelSupportEndpointTypes { + for _, et := range endpoints { + if info, ok := common.GetDefaultEndpointInfo(et); ok { + if _, exists := supportedEndpointMap[string(et)]; !exists { + supportedEndpointMap[string(et)] = info + } + } + } + } + // 2. 自定义端点(models 表)覆盖默认 + for _, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + for k, v := range raw { + switch val := v.(type) { + case string: + supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"} + case map[string]interface{}: + ep := common.EndpointInfo{Method: "POST"} + if p, ok := val["path"].(string); ok { + ep.Path = p + } + if m, ok := val["method"].(string); ok { + ep.Method = strings.ToUpper(m) + } + supportedEndpointMap[k] = ep + default: + // ignore unsupported types + } + } + } + } + + pricingMap = make([]Pricing, 0) for model, groups := range modelGroupsMap { pricing := Pricing{ ModelName: model, @@ -244,3 +300,8 @@ func updatePricing() { lastGetPricingTime = time.Now() } + +// GetSupportedEndpointMap 返回全局端点到路径的映射 +func GetSupportedEndpointMap() map[string]common.EndpointInfo { + return supportedEndpointMap +} diff --git a/web/src/components/common/ui/JSONEditor.js b/web/src/components/common/ui/JSONEditor.js index 649d5a58..fd4064dd 100644 --- a/web/src/components/common/ui/JSONEditor.js +++ b/web/src/components/common/ui/JSONEditor.js @@ -1,25 +1,25 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { - Space, Button, Form, - Card, Typography, Banner, - Row, - Col, + Tabs, + TabPane, + Card, + Input, InputNumber, Switch, - Select, - Input, + TextArea, + Row, + Col, } from '@douyinfe/semi-ui'; import { IconCode, - IconEdit, IconPlus, IconDelete, - IconSetting, + IconRefresh, } from '@douyinfe/semi-icons'; const { Text } = Typography; @@ -34,18 +34,17 @@ const JSONEditor = ({ showClear = true, template, templateLabel, - editorType = 'keyValue', // keyValue, object, region - autosize = true, + editorType = 'keyValue', rules = [], formApi = null, ...props }) => { const { t } = useTranslation(); - + // 初始化JSON数据 const [jsonData, setJsonData] = useState(() => { // 初始化时解析JSON数据 - if (value && value.trim()) { + if (typeof value === 'string' && value.trim()) { try { const parsed = JSON.parse(value); return parsed; @@ -53,13 +52,16 @@ const JSONEditor = ({ return {}; } } + if (typeof value === 'object' && value !== null) { + return value; + } return {}; }); - + // 根据键数量决定默认编辑模式 const [editMode, setEditMode] = useState(() => { // 如果初始JSON数据的键数量大于10个,则默认使用手动模式 - if (value && value.trim()) { + if (typeof value === 'string' && value.trim()) { try { const parsed = JSON.parse(value); const keyCount = Object.keys(parsed).length; @@ -76,7 +78,12 @@ const JSONEditor = ({ // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效) useEffect(() => { try { - const parsed = value && value.trim() ? JSON.parse(value) : {}; + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } setJsonData(parsed); setJsonError(''); } catch (error) { @@ -86,18 +93,17 @@ const JSONEditor = ({ } }, [value]); - // 处理可视化编辑的数据变化 const handleVisualChange = useCallback((newData) => { setJsonData(newData); setJsonError(''); const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2); - + // 通过formApi设置值(如果提供的话) if (formApi && field) { formApi.setValue(field, jsonString); } - + onChange?.(jsonString); }, [onChange, formApi, field]); @@ -127,7 +133,12 @@ const JSONEditor = ({ } else { // 从手动模式切换到可视化模式,需要验证JSON try { - const parsed = value && value.trim() ? JSON.parse(value) : {}; + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } setJsonData(parsed); setJsonError(''); setEditMode('visual'); @@ -143,11 +154,11 @@ const JSONEditor = ({ const addKeyValue = useCallback(() => { const newData = { ...jsonData }; const keys = Object.keys(newData); - let newKey = 'key'; let counter = 1; + let newKey = `field_${counter}`; while (newData.hasOwnProperty(newKey)) { - newKey = `key${counter}`; - counter++; + counter += 1; + newKey = `field_${counter}`; } newData[newKey] = ''; handleVisualChange(newData); @@ -162,11 +173,15 @@ const JSONEditor = ({ // 更新键名 const updateKey = useCallback((oldKey, newKey) => { - if (oldKey === newKey) return; - const newData = { ...jsonData }; - const value = newData[oldKey]; - delete newData[oldKey]; - newData[newKey] = value; + if (oldKey === newKey || !newKey) return; + const newData = {}; + Object.entries(jsonData).forEach(([k, v]) => { + if (k === oldKey) { + newData[newKey] = v; + } else { + newData[k] = v; + } + }); handleVisualChange(newData); }, [jsonData, handleVisualChange]); @@ -181,20 +196,20 @@ const JSONEditor = ({ const fillTemplate = useCallback(() => { if (template) { const templateString = JSON.stringify(template, null, 2); - + // 通过formApi设置值(如果提供的话) if (formApi && field) { formApi.setValue(field, templateString); } - + // 无论哪种模式都要更新值 onChange?.(templateString); - + // 如果是可视化模式,同时更新jsonData if (editMode === 'visual') { setJsonData(template); } - + // 清除错误状态 setJsonError(''); } @@ -215,69 +230,47 @@ const JSONEditor = ({ ); } const entries = Object.entries(jsonData); - + return (
{entries.length === 0 && (
-
- -
{t('暂无数据,点击下方按钮添加键值对')}
)} - + {entries.map(([key, value], index) => ( - - - -
- {t('键名')} - updateKey(key, newKey)} - size="small" - /> -
- - -
- {t('值')} - updateValue(key, newValue)} - size="small" - /> -
- - -
-
- -
-
+ + + updateKey(key, newKey)} + /> + + + {renderValueInput(key, value)} + + + @@ -286,100 +279,61 @@ const JSONEditor = ({ ); }; - // 渲染对象编辑器(用于复杂JSON) - const renderObjectEditor = () => { - const entries = Object.entries(jsonData); - - return ( -
- {entries.length === 0 && ( -
-
- -
- - {t('暂无参数,点击下方按钮添加请求参数')} - -
- )} - - {entries.map(([key, value], index) => ( - - - -
- {t('参数名')} - updateKey(key, newKey)} - size="small" - /> -
- - -
- {t('参数值')} ({typeof value}) - {renderValueInput(key, value)} -
- - -
-
- -
-
- ))} - -
- -
-
- ); - }; + // 添加嵌套对象 + const flattenObject = useCallback((parentKey) => { + const newData = { ...jsonData }; + let primitive = ''; + const obj = newData[parentKey]; + if (obj && typeof obj === 'object') { + const firstKey = Object.keys(obj)[0]; + if (firstKey !== undefined) { + const firstVal = obj[firstKey]; + if (typeof firstVal !== 'object') primitive = firstVal; + } + } + newData[parentKey] = primitive; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); - // 渲染参数值输入控件 + const addNestedObject = useCallback((parentKey) => { + const newData = { ...jsonData }; + if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) { + newData[parentKey] = {}; + } + const existingKeys = Object.keys(newData[parentKey]); + let counter = 1; + let newKey = `field_${counter}`; + while (newData[parentKey].hasOwnProperty(newKey)) { + counter += 1; + newKey = `field_${counter}`; + } + newData[parentKey][newKey] = ''; + handleVisualChange(newData); + }, [jsonData, handleVisualChange]); + + // 渲染参数值输入控件(支持嵌套) const renderValueInput = (key, value) => { const valueType = typeof value; - + if (valueType === 'boolean') { return (
updateValue(key, newValue)} - size="small" /> - + {value ? t('true') : t('false')}
); } - + if (valueType === 'number') { return ( updateValue(key, newValue)} - size="small" style={{ width: '100%' }} step={key === 'temperature' ? 0.1 : 1} precision={key === 'temperature' ? 2 : 0} @@ -387,25 +341,137 @@ const JSONEditor = ({ /> ); } - - // 字符串类型或其他类型 + + if (valueType === 'object' && value !== null) { + // 渲染嵌套对象 + const entries = Object.entries(value); + return ( + + {entries.length === 0 && ( + + {t('空对象,点击下方加号添加字段')} + + )} + + {entries.map(([nestedKey, nestedValue], index) => ( + + + { + const newData = { ...jsonData }; + const oldValue = newData[key][nestedKey]; + delete newData[key][nestedKey]; + newData[key][newKey] = oldValue; + handleVisualChange(newData); + }} + /> + + + {typeof nestedValue === 'object' && nestedValue !== null ? ( +