From 398cdafecf29f5211edd93cbb0525152299a6893 Mon Sep 17 00:00:00 2001 From: CaIon Date: Mon, 27 Jul 2026 15:06:34 +0800 Subject: [PATCH] feat: add New API channel support --- common/api_type.go | 14 ++ common/endpoint_type.go | 3 +- constant/api_type.go | 1 + constant/channel.go | 3 + controller/channel-test.go | 5 +- controller/channel.go | 4 + controller/channel_test_internal_test.go | 73 +++++++++++ controller/channel_upstream_update_test.go | 24 ++++ relay/alpha_search_handler.go | 5 +- relay/channel/newapi/adaptor.go | 121 ++++++++++++++++++ relay/channel/newapi/constants.go | 6 + relay/channel/sub2api/adaptor.go | 108 +--------------- relay/channel/sub2api/adaptor_test.go | 19 +++ relay/common/relay_info.go | 1 + relay/relay_adaptor.go | 3 + relay/responses_handler.go | 20 ++- web/src/features/channels/constants.ts | 12 +- .../lib/__tests__/new-api-channel.test.ts | 97 ++++++++++++++ web/src/features/channels/lib/channel-form.ts | 6 +- .../channels/lib/channel-type-config.ts | 10 ++ .../features/channels/lib/channel-utils.ts | 1 + 21 files changed, 408 insertions(+), 128 deletions(-) create mode 100644 relay/channel/newapi/adaptor.go create mode 100644 relay/channel/newapi/constants.go create mode 100644 web/src/features/channels/lib/__tests__/new-api-channel.test.ts diff --git a/common/api_type.go b/common/api_type.go index 94cf7391..a01129e0 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -79,9 +79,23 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeAdvancedCustom case constant.ChannelTypeSub2API: apiType = constant.APITypeSub2API + case constant.ChannelTypeNewAPI: + apiType = constant.APITypeNewAPI } if apiType == -1 { return constant.APITypeOpenAI, false } return apiType, true } + +func IsResponsesCompactAPIType(apiType int) bool { + switch apiType { + case constant.APITypeOpenAI, + constant.APITypeCodex, + constant.APITypeSub2API, + constant.APITypeNewAPI: + return true + default: + return false + } +} diff --git a/common/endpoint_type.go b/common/endpoint_type.go index fe3eefcd..126df3c8 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -30,10 +30,11 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} case constant.ChannelTypeSora: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} - case constant.ChannelTypeSub2API: + case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI: endpointTypes = []constant.EndpointType{ constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse, + constant.EndpointTypeOpenAIResponseCompact, constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAIAlphaSearch, diff --git a/constant/api_type.go b/constant/api_type.go index a65d915b..2a561c6d 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -38,5 +38,6 @@ const ( APITypeCodex APITypeAdvancedCustom APITypeSub2API + APITypeNewAPI APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index d9903f4f..2a6c4a31 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -57,6 +57,7 @@ const ( ChannelTypeCodex = 57 ChannelTypeAdvancedCustom = 58 ChannelTypeSub2API = 59 + ChannelTypeNewAPI = 60 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -122,6 +123,7 @@ var ChannelBaseURLs = []string{ "https://chatgpt.com", //57 "", //58 "", //59 + "", //60 } var ChannelTypeNames = map[int]string{ @@ -181,6 +183,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeCodex: "ChatGPT Subscription (Codex)", ChannelTypeAdvancedCustom: "Advanced Custom", ChannelTypeSub2API: "Sub2API", + ChannelTypeNewAPI: "New API", } func GetChannelTypeName(channelType int) string { diff --git a/controller/channel-test.go b/controller/channel-test.go index 4ba3698b..153ccb0d 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -271,11 +271,10 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te apiType, _ := common.ChannelType2APIType(channel.Type) if info.RelayMode == relayconstant.RelayModeResponsesCompact && - apiType != constant.APITypeOpenAI && - apiType != constant.APITypeCodex { + !common.IsResponsesCompactAPIType(apiType) { return testResult{ context: c, - localErr: fmt.Errorf("responses compaction test only supports openai/codex channels, got api type %d", apiType), + localErr: fmt.Errorf("responses compaction test is not supported for api type %d", apiType), newAPIError: types.NewError(fmt.Errorf("unsupported api type: %d", apiType), types.ErrorCodeInvalidApiType), } } diff --git a/controller/channel.go b/controller/channel.go index af4c6eec..8b7170d9 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -481,6 +481,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error { return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) } + if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { + return fmt.Errorf("New API channel base URL cannot be empty") + } + // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { if channel.Key == "" { diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 56810fdc..6e1a4eee 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -56,6 +56,79 @@ func TestValidateChannelProxy(t *testing.T) { } } +func TestValidateChannelRequiresNewAPIBaseURL(t *testing.T) { + tests := []struct { + name string + baseURL *string + wantErr bool + }{ + {name: "missing", wantErr: true}, + {name: "blank", baseURL: common.GetPointer(" "), wantErr: true}, + {name: "configured", baseURL: common.GetPointer("https://new-api.example")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + channel := &model.Channel{ + Type: constant.ChannelTypeNewAPI, + BaseURL: test.baseURL, + } + + err := validateChannel(channel, false) + + if test.wantErr { + require.ErrorContains(t, err, "New API channel base URL cannot be empty") + return + } + require.NoError(t, err) + }) + } +} + +func TestNewAPIChannelRegistration(t *testing.T) { + apiType, ok := common.ChannelType2APIType(constant.ChannelTypeNewAPI) + + require.True(t, ok) + assert.Equal(t, constant.APITypeNewAPI, apiType) + assert.Equal(t, "New API", constant.GetChannelTypeName(constant.ChannelTypeNewAPI)) + require.Greater(t, len(constant.ChannelBaseURLs), constant.ChannelTypeNewAPI) + assert.Empty(t, constant.ChannelBaseURLs[constant.ChannelTypeNewAPI]) +} + +func TestResponsesCompactAPITypeSupport(t *testing.T) { + tests := []struct { + name string + apiType int + want bool + }{ + {name: "OpenAI", apiType: constant.APITypeOpenAI, want: true}, + {name: "Codex", apiType: constant.APITypeCodex, want: true}, + {name: "Sub2API", apiType: constant.APITypeSub2API, want: true}, + {name: "New API", apiType: constant.APITypeNewAPI, want: true}, + {name: "Anthropic", apiType: constant.APITypeAnthropic, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, common.IsResponsesCompactAPIType(test.apiType)) + }) + } +} + +func TestMultiprotocolGatewayEndpointTypes(t *testing.T) { + want := []constant.EndpointType{ + constant.EndpointTypeOpenAI, + constant.EndpointTypeOpenAIResponse, + constant.EndpointTypeOpenAIResponseCompact, + constant.EndpointTypeAnthropic, + constant.EndpointTypeGemini, + constant.EndpointTypeOpenAIAlphaSearch, + } + + assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeNewAPI, "gpt-5")) + assert.Equal(t, want, common.GetEndpointTypesByChannelType(constant.ChannelTypeSub2API, "gpt-5")) +} + func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) { db := setupModelListControllerTestDB(t) settingBytes, err := common.Marshal(dto.ChannelSettings{ diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 9265c93b..fd373fcc 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -13,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -384,6 +385,29 @@ func TestFetchModelsUsesSharedChannelFetchBehavior(t *testing.T) { require.JSONEq(t, `{"success":true,"message":"","data":["claude-sonnet"]}`, recorder.Body.String()) } +func TestFetchNewAPIModelsUsesOpenAIContract(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/models", r.URL.Path) + assert.Equal(t, "Bearer new-api-key", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":" gpt-5-mini "}]}`)) + assert.NoError(t, err) + })) + t.Cleanup(server.Close) + + baseURL := server.URL + channel := &model.Channel{ + Type: constant.ChannelTypeNewAPI, + Key: "new-api-key", + BaseURL: &baseURL, + } + + models, err := fetchChannelUpstreamModelIDs(channel) + + require.NoError(t, err) + require.Equal(t, []string{"gpt-5", "gpt-5-mini"}, models) +} + func TestNormalizeModelNames(t *testing.T) { result := normalizeModelNames([]string{ " gpt-4o ", diff --git a/relay/alpha_search_handler.go b/relay/alpha_search_handler.go index 299408fa..da0675da 100644 --- a/relay/alpha_search_handler.go +++ b/relay/alpha_search_handler.go @@ -22,7 +22,10 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError info.InitChannelMeta(c) switch info.ChannelType { - case constant.ChannelTypeSub2API, constant.ChannelTypeCodex, constant.ChannelTypeAdvancedCustom: + case constant.ChannelTypeSub2API, + constant.ChannelTypeNewAPI, + constant.ChannelTypeCodex, + constant.ChannelTypeAdvancedCustom: default: // Allow retry onto another channel that may support this endpoint. return types.NewError( diff --git a/relay/channel/newapi/adaptor.go b/relay/channel/newapi/adaptor.go new file mode 100644 index 00000000..ce1b78ba --- /dev/null +++ b/relay/channel/newapi/adaptor.go @@ -0,0 +1,121 @@ +package newapi + +import ( + "errors" + "io" + "net/http" + + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/claude" + "github.com/QuantumNous/new-api/relay/channel/gemini" + "github.com/QuantumNous/new-api/relay/channel/openai" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + openaiAdaptor openai.Adaptor + claudeAdaptor claude.Adaptor + geminiAdaptor gemini.Adaptor +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.openaiAdaptor.Init(info) + a.claudeAdaptor.Init(info) + a.geminiAdaptor.Init(info) +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == relayconstant.RelayModeAlphaSearch { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, "/v1/alpha/search", info.ChannelType), nil + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + + switch info.RelayFormat { + case types.RelayFormatClaude: + req.Set("x-api-key", info.ApiKey) + if req.Get("anthropic-version") == "" { + anthropicVersion := c.Request.Header.Get("anthropic-version") + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + req.Set("anthropic-version", anthropicVersion) + } + case types.RelayFormatGemini: + req.Set("x-goog-api-key", info.ApiKey) + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("endpoint not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("endpoint not supported") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return a.claudeAdaptor.DoResponse(c, resp, info) + case types.RelayFormatGemini: + return a.geminiAdaptor.DoResponse(c, resp, info) + default: + return a.openaiAdaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/newapi/constants.go b/relay/channel/newapi/constants.go new file mode 100644 index 00000000..19da069b --- /dev/null +++ b/relay/channel/newapi/constants.go @@ -0,0 +1,6 @@ +package newapi + +const ChannelName = "newapi" + +// ModelList is empty because models are fetched dynamically from upstream /v1/models. +var ModelList = []string{} diff --git a/relay/channel/sub2api/adaptor.go b/relay/channel/sub2api/adaptor.go index fa454f2a..8fce5af8 100644 --- a/relay/channel/sub2api/adaptor.go +++ b/relay/channel/sub2api/adaptor.go @@ -1,115 +1,11 @@ package sub2api import ( - "errors" - "io" - "net/http" - - "github.com/QuantumNous/new-api/dto" - "github.com/QuantumNous/new-api/relay/channel" - "github.com/QuantumNous/new-api/relay/channel/claude" - "github.com/QuantumNous/new-api/relay/channel/gemini" - "github.com/QuantumNous/new-api/relay/channel/openai" - relaycommon "github.com/QuantumNous/new-api/relay/common" - relayconstant "github.com/QuantumNous/new-api/relay/constant" - "github.com/QuantumNous/new-api/types" - - "github.com/gin-gonic/gin" + "github.com/QuantumNous/new-api/relay/channel/newapi" ) type Adaptor struct { - openaiAdaptor openai.Adaptor - claudeAdaptor claude.Adaptor - geminiAdaptor gemini.Adaptor -} - -func (a *Adaptor) Init(info *relaycommon.RelayInfo) { - a.openaiAdaptor.Init(info) - a.claudeAdaptor.Init(info) - a.geminiAdaptor.Init(info) -} - -func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - if info.RelayMode == relayconstant.RelayModeAlphaSearch { - return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, "/v1/alpha/search", info.ChannelType), nil - } - return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil -} - -func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - channel.SetupApiRequestHeader(info, c, req) - req.Set("Authorization", "Bearer "+info.ApiKey) - - switch info.RelayFormat { - case types.RelayFormatClaude: - req.Set("x-api-key", info.ApiKey) - if req.Get("anthropic-version") == "" { - anthropicVersion := c.Request.Header.Get("anthropic-version") - if anthropicVersion == "" { - anthropicVersion = "2023-06-01" - } - req.Set("anthropic-version", anthropicVersion) - } - case types.RelayFormatGemini: - req.Set("x-goog-api-key", info.ApiKey) - } - return nil -} - -func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { - if request == nil { - return nil, errors.New("request is nil") - } - return request, nil -} - -func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - return request, nil -} - -func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { - return request, nil -} - -func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { - if request == nil { - return nil, errors.New("request is nil") - } - return request, nil -} - -func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { - if request == nil { - return nil, errors.New("request is nil") - } - return request, nil -} - -func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { - return request, nil -} - -func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { - return nil, errors.New("endpoint not supported") -} - -func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { - return nil, errors.New("endpoint not supported") -} - -func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { - return channel.DoApiRequest(a, c, info, requestBody) -} - -func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { - switch info.RelayFormat { - case types.RelayFormatClaude: - return a.claudeAdaptor.DoResponse(c, resp, info) - case types.RelayFormatGemini: - return a.geminiAdaptor.DoResponse(c, resp, info) - default: - return a.openaiAdaptor.DoResponse(c, resp, info) - } + newapi.Adaptor } func (a *Adaptor) GetModelList() []string { diff --git a/relay/channel/sub2api/adaptor_test.go b/relay/channel/sub2api/adaptor_test.go index ed831963..4ce3912a 100644 --- a/relay/channel/sub2api/adaptor_test.go +++ b/relay/channel/sub2api/adaptor_test.go @@ -25,3 +25,22 @@ func TestGetRequestURLAlphaSearch(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://sub2api.example/v1/alpha/search", url) } + +func TestAdaptorInheritsNewAPIResponsesCompactSupport(t *testing.T) { + adaptor := &Adaptor{} + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeSub2API, + ChannelBaseUrl: "https://sub2api.example", + }, + RequestURLPath: "/v1/responses/compact", + RelayMode: relayconstant.RelayModeResponsesCompact, + } + + url, err := adaptor.GetRequestURL(info) + + require.NoError(t, err) + assert.Equal(t, "https://sub2api.example/v1/responses/compact", url) + assert.Equal(t, "sub2api", adaptor.GetChannelName()) + assert.Empty(t, adaptor.GetModelList()) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 81df500f..eaf488e0 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -343,6 +343,7 @@ var streamSupportedChannels = map[int]bool{ constant.ChannelTypeSiliconFlow: true, constant.ChannelTypeAdvancedCustom: true, constant.ChannelTypeSub2API: true, + constant.ChannelTypeNewAPI: true, constant.ChannelTypeTencent: true, } diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 9f768e4b..e6298dc0 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -24,6 +24,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/mistral" "github.com/QuantumNous/new-api/relay/channel/mokaai" "github.com/QuantumNous/new-api/relay/channel/moonshot" + "github.com/QuantumNous/new-api/relay/channel/newapi" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/relay/channel/openai" "github.com/QuantumNous/new-api/relay/channel/palm" @@ -126,6 +127,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &advancedcustom.Adaptor{} case constant.APITypeSub2API: return &sub2api.Adaptor{} + case constant.APITypeNewAPI: + return &newapi.Adaptor{} } return nil } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 5fa23d09..381c41e2 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/QuantumNous/new-api/common" - appconstant "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" @@ -22,17 +21,14 @@ import ( func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { info.InitChannelMeta(c) - if info.RelayMode == relayconstant.RelayModeResponsesCompact { - switch info.ApiType { - case appconstant.APITypeOpenAI, appconstant.APITypeCodex: - default: - return types.NewErrorWithStatusCode( - fmt.Errorf("unsupported endpoint %q for api type %d", "/v1/responses/compact", info.ApiType), - types.ErrorCodeInvalidRequest, - http.StatusBadRequest, - types.ErrOptionWithSkipRetry(), - ) - } + if info.RelayMode == relayconstant.RelayModeResponsesCompact && + !common.IsResponsesCompactAPIType(info.ApiType) { + return types.NewErrorWithStatusCode( + fmt.Errorf("unsupported endpoint %q for api type %d", "/v1/responses/compact", info.ApiType), + types.ErrorCodeInvalidRequest, + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) } var responsesReq *dto.OpenAIResponsesRequest diff --git a/web/src/features/channels/constants.ts b/web/src/features/channels/constants.ts index 008494c0..fbca11e4 100644 --- a/web/src/features/channels/constants.ts +++ b/web/src/features/channels/constants.ts @@ -21,6 +21,8 @@ For commercial licensing, please contact support@quantumnous.com // All label/name values are i18n keys; use t(value) when displaying. // ============================================================================ +export const CHANNEL_TYPE_NEW_API = 60 + export const CHANNEL_TYPES = { 0: 'Unknown', 1: 'OpenAI', @@ -78,12 +80,13 @@ export const CHANNEL_TYPES = { 57: 'ChatGPT Subscription (Codex)', 58: 'Advanced Custom', 59: 'Sub2API', + 60: 'New API', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ - 1, 14, 33, 24, 43, 3, 41, 48, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, - 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2, 5, - 36, 50, 51, 52, 53, 54, 55, 56, + 1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, + 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2, + 5, 36, 50, 51, 52, 53, 54, 55, 56, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { @@ -381,7 +384,7 @@ export const FIELD_DESCRIPTIONS = { export const MODEL_FETCHABLE_TYPES = new Set([ 1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58, - 59, + 59, 60, ]) export const TYPE_TO_KEY_PROMPT: Record = { @@ -394,6 +397,7 @@ export const TYPE_TO_KEY_PROMPT: Record = { 51: 'Format: Access Key ID|Secret Access Key', 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', 59: 'Enter API key for this channel', + 60: 'Enter API key for this channel', } export const CHANNEL_TYPE_WARNINGS: Record = { diff --git a/web/src/features/channels/lib/__tests__/new-api-channel.test.ts b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts new file mode 100644 index 00000000..5e4f6c9b --- /dev/null +++ b/web/src/features/channels/lib/__tests__/new-api-channel.test.ts @@ -0,0 +1,97 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + CHANNEL_TYPE_NEW_API, + CHANNEL_TYPE_OPTIONS, + MODEL_FETCHABLE_TYPES, +} from '../../constants' +import { CHANNEL_FORM_DEFAULT_VALUES, channelFormSchema } from '../channel-form' +import { getChannelTypeConfig } from '../channel-type-config' +import { getChannelTypeIcon, getKeyPromptForType } from '../channel-utils' + +function newAPIForm(baseUrl: string) { + return { + ...CHANNEL_FORM_DEFAULT_VALUES, + name: 'New API upstream', + type: CHANNEL_TYPE_NEW_API, + base_url: baseUrl, + key: 'test-key', + models: 'gpt-5', + } +} + +describe('New API channel', () => { + test('registers selection, ordering, model discovery, and icon metadata', () => { + const option = CHANNEL_TYPE_OPTIONS.find( + (item) => item.value === CHANNEL_TYPE_NEW_API + ) + + assert.deepEqual(option, { + value: CHANNEL_TYPE_NEW_API, + label: 'New API', + }) + assert.equal( + CHANNEL_TYPE_OPTIONS.findIndex( + (item) => item.value === CHANNEL_TYPE_NEW_API + ) + 1, + CHANNEL_TYPE_OPTIONS.findIndex((item) => item.value === 58) + ) + assert.equal(MODEL_FETCHABLE_TYPES.has(CHANNEL_TYPE_NEW_API), true) + assert.equal(getChannelTypeIcon(CHANNEL_TYPE_NEW_API), 'NewAPI') + assert.equal( + getKeyPromptForType(CHANNEL_TYPE_NEW_API), + 'Enter API key for this channel' + ) + assert.equal(getChannelTypeConfig(CHANNEL_TYPE_NEW_API).icon, 'NewAPI') + }) + + test('requires a non-blank Base URL', () => { + const blankResult = channelFormSchema.safeParse(newAPIForm(' ')) + + assert.equal(blankResult.success, false) + if (!blankResult.success) { + assert.equal( + blankResult.error.issues.some( + (issue) => + issue.path[0] === 'base_url' && + issue.message === 'Base URL is required for this channel type' + ), + true + ) + } + + assert.equal( + channelFormSchema.safeParse(newAPIForm('https://new-api.example')) + .success, + true + ) + }) + + test('keeps Sub2API Base URL validation unchanged', () => { + const result = channelFormSchema.safeParse({ + ...newAPIForm(''), + type: 59, + }) + + assert.equal(result.success, true) + }) +}) diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index e7e36827..f7a91b25 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { z } from 'zod' import { + CHANNEL_TYPE_NEW_API, CHANNEL_STATUS, ERROR_MESSAGES, MODEL_FETCHABLE_TYPES, @@ -247,7 +248,10 @@ export const channelFormSchema = z upstream_model_update_ignored_models: z.string().optional(), }) .superRefine((data, ctx) => { - if ([3, 8, 36, 45].includes(data.type) && !data.base_url?.trim()) { + if ( + [3, 8, 36, 45, CHANNEL_TYPE_NEW_API].includes(data.type) && + !data.base_url?.trim() + ) { addRequiredIssue( ctx, 'base_url', diff --git a/web/src/features/channels/lib/channel-type-config.ts b/web/src/features/channels/lib/channel-type-config.ts index dec5eb89..8a05b86e 100644 --- a/web/src/features/channels/lib/channel-type-config.ts +++ b/web/src/features/channels/lib/channel-type-config.ts @@ -154,6 +154,16 @@ export const CHANNEL_TYPE_CONFIGS: Record = { models: 'Models fetched from upstream /v1/models', }, }, + 60: { + id: 60, + name: CHANNEL_TYPES[60], + icon: 'NewAPI', + hints: { + baseUrl: 'Base URL is required for this channel type', + key: 'Enter API key for this channel', + models: 'Models', + }, + }, } /** diff --git a/web/src/features/channels/lib/channel-utils.ts b/web/src/features/channels/lib/channel-utils.ts index 98d85251..9424a852 100644 --- a/web/src/features/channels/lib/channel-utils.ts +++ b/web/src/features/channels/lib/channel-utils.ts @@ -53,6 +53,7 @@ export function getChannelTypeIcon(type: number): string { 8: 'OpenAI', // Custom 58: 'NewAPI', // Advanced Custom 59: 'Sub2API', // Sub2API + 60: 'NewAPI', // New API 3: 'Azure', // Azure // Anthropic