mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 23:30:35 +00:00
feat: add New API channel support
This commit is contained in:
@@ -79,9 +79,23 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
|||||||
apiType = constant.APITypeAdvancedCustom
|
apiType = constant.APITypeAdvancedCustom
|
||||||
case constant.ChannelTypeSub2API:
|
case constant.ChannelTypeSub2API:
|
||||||
apiType = constant.APITypeSub2API
|
apiType = constant.APITypeSub2API
|
||||||
|
case constant.ChannelTypeNewAPI:
|
||||||
|
apiType = constant.APITypeNewAPI
|
||||||
}
|
}
|
||||||
if apiType == -1 {
|
if apiType == -1 {
|
||||||
return constant.APITypeOpenAI, false
|
return constant.APITypeOpenAI, false
|
||||||
}
|
}
|
||||||
return apiType, true
|
return apiType, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsResponsesCompactAPIType(apiType int) bool {
|
||||||
|
switch apiType {
|
||||||
|
case constant.APITypeOpenAI,
|
||||||
|
constant.APITypeCodex,
|
||||||
|
constant.APITypeSub2API,
|
||||||
|
constant.APITypeNewAPI:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
|
|||||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
|
||||||
case constant.ChannelTypeSora:
|
case constant.ChannelTypeSora:
|
||||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
|
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
|
||||||
case constant.ChannelTypeSub2API:
|
case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI:
|
||||||
endpointTypes = []constant.EndpointType{
|
endpointTypes = []constant.EndpointType{
|
||||||
constant.EndpointTypeOpenAI,
|
constant.EndpointTypeOpenAI,
|
||||||
constant.EndpointTypeOpenAIResponse,
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
constant.EndpointTypeOpenAIResponseCompact,
|
||||||
constant.EndpointTypeAnthropic,
|
constant.EndpointTypeAnthropic,
|
||||||
constant.EndpointTypeGemini,
|
constant.EndpointTypeGemini,
|
||||||
constant.EndpointTypeOpenAIAlphaSearch,
|
constant.EndpointTypeOpenAIAlphaSearch,
|
||||||
|
|||||||
@@ -38,5 +38,6 @@ const (
|
|||||||
APITypeCodex
|
APITypeCodex
|
||||||
APITypeAdvancedCustom
|
APITypeAdvancedCustom
|
||||||
APITypeSub2API
|
APITypeSub2API
|
||||||
|
APITypeNewAPI
|
||||||
APITypeDummy // this one is only for count, do not add any channel after this
|
APITypeDummy // this one is only for count, do not add any channel after this
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const (
|
|||||||
ChannelTypeCodex = 57
|
ChannelTypeCodex = 57
|
||||||
ChannelTypeAdvancedCustom = 58
|
ChannelTypeAdvancedCustom = 58
|
||||||
ChannelTypeSub2API = 59
|
ChannelTypeSub2API = 59
|
||||||
|
ChannelTypeNewAPI = 60
|
||||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
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
|
"https://chatgpt.com", //57
|
||||||
"", //58
|
"", //58
|
||||||
"", //59
|
"", //59
|
||||||
|
"", //60
|
||||||
}
|
}
|
||||||
|
|
||||||
var ChannelTypeNames = map[int]string{
|
var ChannelTypeNames = map[int]string{
|
||||||
@@ -181,6 +183,7 @@ var ChannelTypeNames = map[int]string{
|
|||||||
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
|
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
|
||||||
ChannelTypeAdvancedCustom: "Advanced Custom",
|
ChannelTypeAdvancedCustom: "Advanced Custom",
|
||||||
ChannelTypeSub2API: "Sub2API",
|
ChannelTypeSub2API: "Sub2API",
|
||||||
|
ChannelTypeNewAPI: "New API",
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetChannelTypeName(channelType int) string {
|
func GetChannelTypeName(channelType int) string {
|
||||||
|
|||||||
@@ -271,11 +271,10 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
|
|||||||
|
|
||||||
apiType, _ := common.ChannelType2APIType(channel.Type)
|
apiType, _ := common.ChannelType2APIType(channel.Type)
|
||||||
if info.RelayMode == relayconstant.RelayModeResponsesCompact &&
|
if info.RelayMode == relayconstant.RelayModeResponsesCompact &&
|
||||||
apiType != constant.APITypeOpenAI &&
|
!common.IsResponsesCompactAPIType(apiType) {
|
||||||
apiType != constant.APITypeCodex {
|
|
||||||
return testResult{
|
return testResult{
|
||||||
context: c,
|
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),
|
newAPIError: types.NewError(fmt.Errorf("unsupported api type: %d", apiType), types.ErrorCodeInvalidApiType),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
|||||||
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.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 是否为空
|
// 如果是添加操作,检查 channel 和 key 是否为空
|
||||||
if isAdd {
|
if isAdd {
|
||||||
if channel.Key == "" {
|
if channel.Key == "" {
|
||||||
|
|||||||
@@ -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) {
|
func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) {
|
||||||
db := setupModelListControllerTestDB(t)
|
db := setupModelListControllerTestDB(t)
|
||||||
settingBytes, err := common.Marshal(dto.ChannelSettings{
|
settingBytes, err := common.Marshal(dto.ChannelSettings{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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())
|
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) {
|
func TestNormalizeModelNames(t *testing.T) {
|
||||||
result := normalizeModelNames([]string{
|
result := normalizeModelNames([]string{
|
||||||
" gpt-4o ",
|
" gpt-4o ",
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError
|
|||||||
info.InitChannelMeta(c)
|
info.InitChannelMeta(c)
|
||||||
|
|
||||||
switch info.ChannelType {
|
switch info.ChannelType {
|
||||||
case constant.ChannelTypeSub2API, constant.ChannelTypeCodex, constant.ChannelTypeAdvancedCustom:
|
case constant.ChannelTypeSub2API,
|
||||||
|
constant.ChannelTypeNewAPI,
|
||||||
|
constant.ChannelTypeCodex,
|
||||||
|
constant.ChannelTypeAdvancedCustom:
|
||||||
default:
|
default:
|
||||||
// Allow retry onto another channel that may support this endpoint.
|
// Allow retry onto another channel that may support this endpoint.
|
||||||
return types.NewError(
|
return types.NewError(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package newapi
|
||||||
|
|
||||||
|
const ChannelName = "newapi"
|
||||||
|
|
||||||
|
// ModelList is empty because models are fetched dynamically from upstream /v1/models.
|
||||||
|
var ModelList = []string{}
|
||||||
@@ -1,115 +1,11 @@
|
|||||||
package sub2api
|
package sub2api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"github.com/QuantumNous/new-api/relay/channel/newapi"
|
||||||
"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 {
|
type Adaptor struct {
|
||||||
openaiAdaptor openai.Adaptor
|
newapi.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 {
|
func (a *Adaptor) GetModelList() []string {
|
||||||
|
|||||||
@@ -25,3 +25,22 @@ func TestGetRequestURLAlphaSearch(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "https://sub2api.example/v1/alpha/search", url)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ var streamSupportedChannels = map[int]bool{
|
|||||||
constant.ChannelTypeSiliconFlow: true,
|
constant.ChannelTypeSiliconFlow: true,
|
||||||
constant.ChannelTypeAdvancedCustom: true,
|
constant.ChannelTypeAdvancedCustom: true,
|
||||||
constant.ChannelTypeSub2API: true,
|
constant.ChannelTypeSub2API: true,
|
||||||
|
constant.ChannelTypeNewAPI: true,
|
||||||
constant.ChannelTypeTencent: true,
|
constant.ChannelTypeTencent: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/relay/channel/mistral"
|
"github.com/QuantumNous/new-api/relay/channel/mistral"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/mokaai"
|
"github.com/QuantumNous/new-api/relay/channel/mokaai"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/moonshot"
|
"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/ollama"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/palm"
|
"github.com/QuantumNous/new-api/relay/channel/palm"
|
||||||
@@ -126,6 +127,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
|||||||
return &advancedcustom.Adaptor{}
|
return &advancedcustom.Adaptor{}
|
||||||
case constant.APITypeSub2API:
|
case constant.APITypeSub2API:
|
||||||
return &sub2api.Adaptor{}
|
return &sub2api.Adaptor{}
|
||||||
|
case constant.APITypeNewAPI:
|
||||||
|
return &newapi.Adaptor{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
appconstant "github.com/QuantumNous/new-api/constant"
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
@@ -22,17 +21,14 @@ import (
|
|||||||
|
|
||||||
func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
|
func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
|
||||||
info.InitChannelMeta(c)
|
info.InitChannelMeta(c)
|
||||||
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
if info.RelayMode == relayconstant.RelayModeResponsesCompact &&
|
||||||
switch info.ApiType {
|
!common.IsResponsesCompactAPIType(info.ApiType) {
|
||||||
case appconstant.APITypeOpenAI, appconstant.APITypeCodex:
|
return types.NewErrorWithStatusCode(
|
||||||
default:
|
fmt.Errorf("unsupported endpoint %q for api type %d", "/v1/responses/compact", info.ApiType),
|
||||||
return types.NewErrorWithStatusCode(
|
types.ErrorCodeInvalidRequest,
|
||||||
fmt.Errorf("unsupported endpoint %q for api type %d", "/v1/responses/compact", info.ApiType),
|
http.StatusBadRequest,
|
||||||
types.ErrorCodeInvalidRequest,
|
types.ErrOptionWithSkipRetry(),
|
||||||
http.StatusBadRequest,
|
)
|
||||||
types.ErrOptionWithSkipRetry(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var responsesReq *dto.OpenAIResponsesRequest
|
var responsesReq *dto.OpenAIResponsesRequest
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
// All label/name values are i18n keys; use t(value) when displaying.
|
// All label/name values are i18n keys; use t(value) when displaying.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CHANNEL_TYPE_NEW_API = 60
|
||||||
|
|
||||||
export const CHANNEL_TYPES = {
|
export const CHANNEL_TYPES = {
|
||||||
0: 'Unknown',
|
0: 'Unknown',
|
||||||
1: 'OpenAI',
|
1: 'OpenAI',
|
||||||
@@ -78,12 +80,13 @@ export const CHANNEL_TYPES = {
|
|||||||
57: 'ChatGPT Subscription (Codex)',
|
57: 'ChatGPT Subscription (Codex)',
|
||||||
58: 'Advanced Custom',
|
58: 'Advanced Custom',
|
||||||
59: 'Sub2API',
|
59: 'Sub2API',
|
||||||
|
60: 'New API',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
|
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,
|
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15,
|
||||||
23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2, 5,
|
46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2,
|
||||||
36, 50, 51, 52, 53, 54, 55, 56,
|
5, 36, 50, 51, 52, 53, 54, 55, 56,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
|
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
|
||||||
@@ -381,7 +384,7 @@ export const FIELD_DESCRIPTIONS = {
|
|||||||
|
|
||||||
export const MODEL_FETCHABLE_TYPES = new Set([
|
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,
|
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<number, string> = {
|
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||||
@@ -394,6 +397,7 @@ export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
|||||||
51: 'Format: Access Key ID|Secret Access Key',
|
51: 'Format: Access Key ID|Secret Access Key',
|
||||||
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
|
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
|
||||||
59: 'Enter API key for this channel',
|
59: 'Enter API key for this channel',
|
||||||
|
60: 'Enter API key for this channel',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
|
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CHANNEL_TYPE_NEW_API,
|
||||||
CHANNEL_STATUS,
|
CHANNEL_STATUS,
|
||||||
ERROR_MESSAGES,
|
ERROR_MESSAGES,
|
||||||
MODEL_FETCHABLE_TYPES,
|
MODEL_FETCHABLE_TYPES,
|
||||||
@@ -247,7 +248,10 @@ export const channelFormSchema = z
|
|||||||
upstream_model_update_ignored_models: z.string().optional(),
|
upstream_model_update_ignored_models: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.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(
|
addRequiredIssue(
|
||||||
ctx,
|
ctx,
|
||||||
'base_url',
|
'base_url',
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
|
|||||||
models: 'Models fetched from upstream /v1/models',
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function getChannelTypeIcon(type: number): string {
|
|||||||
8: 'OpenAI', // Custom
|
8: 'OpenAI', // Custom
|
||||||
58: 'NewAPI', // Advanced Custom
|
58: 'NewAPI', // Advanced Custom
|
||||||
59: 'Sub2API', // Sub2API
|
59: 'Sub2API', // Sub2API
|
||||||
|
60: 'NewAPI', // New API
|
||||||
3: 'Azure', // Azure
|
3: 'Azure', // Azure
|
||||||
|
|
||||||
// Anthropic
|
// Anthropic
|
||||||
|
|||||||
Reference in New Issue
Block a user