Commit 5b268d85 by t0ng7u

feat(pricing+endpoints+ui): wire custom endpoint mapping end‑to‑end and…

 feat(pricing+endpoints+ui): wire custom endpoint mapping end‑to‑end and overhaul visual JSON editor

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.
parent 05cf55ac
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
}
...@@ -44,6 +44,7 @@ func GetPricing(c *gin.Context) { ...@@ -44,6 +44,7 @@ func GetPricing(c *gin.Context) {
"vendors": model.GetVendors(), "vendors": model.GetVendors(),
"group_ratio": groupRatio, "group_ratio": groupRatio,
"usable_group": usableGroup, "usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
}) })
} }
......
package model package model
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"one-api/common" "one-api/common"
"one-api/constant" "one-api/constant"
"one-api/setting/ratio_setting" "one-api/setting/ratio_setting"
...@@ -35,6 +37,7 @@ type PricingVendor struct { ...@@ -35,6 +37,7 @@ type PricingVendor struct {
var ( var (
pricingMap []Pricing pricingMap []Pricing
vendorsList []PricingVendor vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time lastGetPricingTime time.Time
updatePricingLock sync.Mutex updatePricingLock sync.Mutex
...@@ -176,12 +179,9 @@ func updatePricing() { ...@@ -176,12 +179,9 @@ func updatePricing() {
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string) modelSupportEndpointsStr := make(map[string][]string)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities { for _, ability := range enableAbilities {
endpoints, ok := modelSupportEndpointsStr[ability.Model] endpoints := modelSupportEndpointsStr[ability.Model]
if !ok {
endpoints = make([]string, 0)
modelSupportEndpointsStr[ability.Model] = endpoints
}
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes { for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) { if !common.StringsContains(endpoints, string(channelType)) {
...@@ -191,6 +191,23 @@ func updatePricing() { ...@@ -191,6 +191,23 @@ func updatePricing() {
modelSupportEndpointsStr[ability.Model] = endpoints 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) 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)
...@@ -201,6 +218,45 @@ func updatePricing() { ...@@ -201,6 +218,45 @@ func updatePricing() {
modelSupportEndpointTypes[model] = supportedEndpoints modelSupportEndpointTypes[model] = supportedEndpoints
} }
// 构建全局 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) pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap { for model, groups := range modelGroupsMap {
pricing := Pricing{ pricing := Pricing{
...@@ -244,3 +300,8 @@ func updatePricing() { ...@@ -244,3 +300,8 @@ func updatePricing() {
lastGetPricingTime = time.Now() lastGetPricingTime = time.Now()
} }
// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}
...@@ -80,6 +80,7 @@ const PricingPage = () => { ...@@ -80,6 +80,7 @@ const PricingPage = () => {
displayPrice={pricingData.displayPrice} displayPrice={pricingData.displayPrice}
showRatio={allProps.showRatio} showRatio={allProps.showRatio}
vendorsMap={pricingData.vendorsMap} vendorsMap={pricingData.vendorsMap}
endpointMap={pricingData.endpointMap}
t={pricingData.t} t={pricingData.t}
/> />
</div> </div>
......
...@@ -47,6 +47,7 @@ const ModelDetailSideSheet = ({ ...@@ -47,6 +47,7 @@ const ModelDetailSideSheet = ({
showRatio, showRatio,
usableGroup, usableGroup,
vendorsMap, vendorsMap,
endpointMap,
t, t,
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -82,7 +83,7 @@ const ModelDetailSideSheet = ({ ...@@ -82,7 +83,7 @@ const ModelDetailSideSheet = ({
{modelData && ( {modelData && (
<> <>
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} /> <ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
<ModelEndpoints modelData={modelData} t={t} /> <ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
<ModelPricingTable <ModelPricingTable
modelData={modelData} modelData={modelData}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
......
...@@ -23,31 +23,45 @@ import { IconLink } from '@douyinfe/semi-icons'; ...@@ -23,31 +23,45 @@ import { IconLink } from '@douyinfe/semi-icons';
const { Text } = Typography; const { Text } = Typography;
const ModelEndpoints = ({ modelData, t }) => { const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
const renderAPIEndpoints = () => { const renderAPIEndpoints = () => {
const endpoints = []; if (!modelData) return null;
if (modelData?.supported_endpoint_types) { const mapping = endpointMap;
modelData.supported_endpoint_types.forEach(endpoint => { const types = modelData.supported_endpoint_types || [];
endpoints.push({ name: endpoint, type: endpoint });
});
}
return endpoints.map((endpoint, index) => ( return types.map(type => {
const info = mapping[type] || {};
let path = info.path || '';
// 如果路径中包含 {model} 占位符,替换为真实模型名称
if (path.includes('{model}')) {
const modelName = modelData.model_name || modelData.modelName || '';
path = path.replaceAll('{model}', modelName);
}
const method = info.method || 'POST';
return (
<div <div
key={index} key={type}
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0" className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
style={{ borderColor: 'var(--semi-color-border)' }} style={{ borderColor: 'var(--semi-color-border)' }}
> >
<span className="flex items-center pr-5"> <span className="flex items-center pr-5">
<Badge dot type="success" className="mr-2" /> <Badge dot type="success" className="mr-2" />
{endpoint.name} {type}{path && ':'}
<span className="text-gray-500 hidden md:inline">https://api.newapi.pro</span> {path && (
/v1/chat/completions <span className="text-gray-500 md:ml-1 break-all">
{path}
</span> </span>
<span className="text-gray-500 text-xs hidden md:inline">POST</span> )}
</span>
{path && (
<span className="text-gray-500 text-xs md:ml-1">
{method}
</span>
)}
</div> </div>
)); );
});
}; };
return ( return (
......
...@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,6 +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 { import {
SideSheet, SideSheet,
Form, Form,
...@@ -109,7 +110,7 @@ const EditModelModal = (props) => { ...@@ -109,7 +110,7 @@ const EditModelModal = (props) => {
vendor_id: undefined, vendor_id: undefined,
vendor: '', vendor: '',
vendor_icon: '', vendor_icon: '',
endpoints: [], endpoints: '',
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配 name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
status: true, status: true,
}); });
...@@ -132,15 +133,9 @@ const EditModelModal = (props) => { ...@@ -132,15 +133,9 @@ const EditModelModal = (props) => {
} else { } else {
data.tags = []; data.tags = [];
} }
// 处理endpoints // endpoints 保持原始 JSON 字符串,若为空设为空串
if (data.endpoints) { if (!data.endpoints) {
try { data.endpoints = '';
data.endpoints = JSON.parse(data.endpoints);
} catch (e) {
data.endpoints = [];
}
} else {
data.endpoints = [];
} }
// 处理status,将数字转为布尔值 // 处理status,将数字转为布尔值
data.status = data.status === 1; data.status = data.status === 1;
...@@ -188,7 +183,7 @@ const EditModelModal = (props) => { ...@@ -188,7 +183,7 @@ const EditModelModal = (props) => {
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: JSON.stringify(values.endpoints || []), endpoints: values.endpoints || '',
status: values.status ? 1 : 0, status: values.status ? 1 : 0,
}; };
...@@ -382,36 +377,15 @@ const EditModelModal = (props) => { ...@@ -382,36 +377,15 @@ const EditModelModal = (props) => {
/> />
</Col> </Col>
<Col span={24}> <Col span={24}>
<Form.TagInput <JSONEditor
field='endpoints' field='endpoints'
label={t('支持端点')} label={t('端点映射')}
placeholder={t('输入端点名称,按回车添加')} placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
addOnBlur value={values.endpoints}
showClear onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
style={{ width: '100%' }} formApi={formApiRef.current}
{...(endpointGroups.length > 0 && { editorType='object'
extraText: ( extraText={t('留空则使用默认端点;支持 {path, method}')}
<Space wrap>
{endpointGroups.map(group => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
if (formApiRef.current) {
const currentEndpoints = formApiRef.current.getValue('endpoints') || [];
const newEndpoints = [...currentEndpoints, ...(group.items || [])];
const uniqueEndpoints = [...new Set(newEndpoints)];
formApiRef.current.setValue('endpoints', uniqueEndpoints);
}
}}
>
{group.name}
</Button>
))}
</Space>
)
})}
/> />
</Col> </Col>
<Col span={24}> <Col span={24}>
......
...@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import JSONEditor from '../../../common/ui/JSONEditor';
import { import {
SideSheet, SideSheet,
Button, Button,
...@@ -49,6 +50,13 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -49,6 +50,13 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
const formRef = useRef(null); const formRef = useRef(null);
const isEdit = editingGroup && editingGroup.id !== undefined; const isEdit = editingGroup && editingGroup.id !== undefined;
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
// 当外部传入的编辑组类型变化时同步 selectedType
useEffect(() => {
setSelectedType(editingGroup?.type || 'tag');
}, [editingGroup?.type]);
const typeOptions = [ const typeOptions = [
{ label: t('模型组'), value: 'model' }, { label: t('模型组'), value: 'model' },
{ label: t('标签组'), value: 'tag' }, { label: t('标签组'), value: 'tag' },
...@@ -61,8 +69,12 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -61,8 +69,12 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
try { try {
const submitData = { const submitData = {
...values, ...values,
items: Array.isArray(values.items) ? values.items : [],
}; };
if (values.type === 'endpoint') {
submitData.items = values.items || '';
} else {
submitData.items = Array.isArray(values.items) ? values.items : [];
}
if (editingGroup.id) { if (editingGroup.id) {
submitData.id = editingGroup.id; submitData.id = editingGroup.id;
...@@ -146,11 +158,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -146,11 +158,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
description: editingGroup?.description || '', description: editingGroup?.description || '',
items: (() => { items: (() => {
try { try {
if (editingGroup?.type === 'endpoint') {
// 保持原始字符串
return typeof editingGroup?.items === 'string' return typeof editingGroup?.items === 'string'
? JSON.parse(editingGroup.items) ? editingGroup.items
: editingGroup?.items || []; : JSON.stringify(editingGroup.items || {}, null, 2);
}
return Array.isArray(editingGroup?.items)
? editingGroup.items
: [];
} catch { } catch {
return []; return editingGroup?.type === 'endpoint' ? '' : [];
} }
})(), })(),
}} }}
...@@ -186,6 +204,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -186,6 +204,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
optionList={typeOptions} optionList={typeOptions}
rules={[{ required: true, message: t('请选择组类型') }]} rules={[{ required: true, message: t('请选择组类型') }]}
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(val) => setSelectedType(val)}
/> />
</Col> </Col>
<Col span={24}> <Col span={24}>
...@@ -213,6 +232,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -213,6 +232,17 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
</div> </div>
<Row gutter={12}> <Row gutter={12}>
<Col span={24}> <Col span={24}>
{selectedType === 'endpoint' ? (
<JSONEditor
field="items"
label={t('端点映射')}
value={formRef.current?.getValue('items') ?? (typeof editingGroup?.items === 'string' ? editingGroup.items : JSON.stringify(editingGroup.items || {}, null, 2))}
onChange={(val) => formRef.current?.setValue('items', val)}
editorType='object'
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
extraText={t('键为端点类型,值为路径和方法对象')}
/>
) : (
<Form.TagInput <Form.TagInput
field="items" field="items"
label={t('项目')} label={t('项目')}
...@@ -221,6 +251,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => ...@@ -221,6 +251,7 @@ const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) =>
showClear showClear
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
)}
</Col> </Col>
</Row> </Row>
</Card> </Card>
......
...@@ -137,8 +137,22 @@ const PrefillGroupManagement = ({ visible, onClose }) => { ...@@ -137,8 +137,22 @@ const PrefillGroupManagement = ({ visible, onClose }) => {
title: t('项目内容'), title: t('项目内容'),
dataIndex: 'items', dataIndex: 'items',
key: 'items', key: 'items',
render: (items) => { render: (items, record) => {
try { try {
if (record.type === 'endpoint') {
const obj = typeof items === 'string' ? JSON.parse(items || '{}') : (items || {});
const keys = Object.keys(obj);
if (keys.length === 0) return <Text type="tertiary">{t('暂无项目')}</Text>;
return renderLimitedItems({
items: keys,
renderItem: (key, idx) => (
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
{key}
</Tag>
),
maxDisplay: 3,
});
}
const itemsArray = typeof items === 'string' ? JSON.parse(items) : items; const itemsArray = typeof items === 'string' ? JSON.parse(items) : items;
if (!Array.isArray(itemsArray) || itemsArray.length === 0) { if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
return <Text type="tertiary">{t('暂无项目')}</Text>; return <Text type="tertiary">{t('暂无项目')}</Text>;
......
...@@ -48,6 +48,7 @@ export const useModelPricingData = () => { ...@@ -48,6 +48,7 @@ export const useModelPricingData = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groupRatio, setGroupRatio] = useState({}); const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({}); const [usableGroup, setUsableGroup] = useState({});
const [endpointMap, setEndpointMap] = useState({});
const [statusState] = useContext(StatusContext); const [statusState] = useContext(StatusContext);
const [userState] = useContext(UserContext); const [userState] = useContext(UserContext);
...@@ -159,7 +160,7 @@ export const useModelPricingData = () => { ...@@ -159,7 +160,7 @@ export const useModelPricingData = () => {
setLoading(true); setLoading(true);
let url = '/api/pricing'; let url = '/api/pricing';
const res = await API.get(url); const res = await API.get(url);
const { success, message, data, vendors, group_ratio, usable_group } = res.data; const { success, message, data, vendors, group_ratio, usable_group, supported_endpoint } = res.data;
if (success) { if (success) {
setGroupRatio(group_ratio); setGroupRatio(group_ratio);
setUsableGroup(usable_group); setUsableGroup(usable_group);
...@@ -172,6 +173,7 @@ export const useModelPricingData = () => { ...@@ -172,6 +173,7 @@ export const useModelPricingData = () => {
}); });
} }
setVendorsMap(vendorMap); setVendorsMap(vendorMap);
setEndpointMap(supported_endpoint || {});
setModelsFormat(data, group_ratio, vendorMap); setModelsFormat(data, group_ratio, vendorMap);
} else { } else {
showError(message); showError(message);
...@@ -279,6 +281,7 @@ export const useModelPricingData = () => { ...@@ -279,6 +281,7 @@ export const useModelPricingData = () => {
loading, loading,
groupRatio, groupRatio,
usableGroup, usableGroup,
endpointMap,
// 计算属性 // 计算属性
priceRate, priceRate,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment