Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeCodex
case constant.ChannelTypeAdvancedCustom:
apiType = constant.APITypeAdvancedCustom
case constant.ChannelTypeAwsOpenAI:
apiType = constant.APITypeOpenAI
}
if apiType == -1 {
return constant.APITypeOpenAI, false
Expand Down
56 changes: 56 additions & 0 deletions common/bedrock_openai_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package common

import (
"testing"

"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/require"
)

func TestBedrockOpenAIModelClassification(t *testing.T) {
tests := []struct {
name string
model string
responsesOnly bool
mantleChat bool
runtimeChat bool
}{
{name: "frontier", model: "openai.gpt-5.6-sol", responsesOnly: true},
{name: "frontier effort suffix", model: "openai.gpt-5.5-xhigh", responsesOnly: true},
{name: "mantle gpt oss", model: "openai.gpt-oss-120b", mantleChat: true},
{name: "safeguard chat only", model: "openai.gpt-oss-safeguard-20b"},
{name: "runtime gpt oss", model: "openai.gpt-oss-20b-1:0", runtimeChat: true},
{name: "unrelated", model: "gpt-5.6-sol"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.responsesOnly, IsBedrockOpenAIResponsesModel(tt.model))
require.Equal(t, tt.mantleChat, IsBedrockOpenAIMantleChatModel(tt.model))
require.Equal(t, tt.runtimeChat, IsBedrockOpenAIRuntimeChatModel(tt.model))
})
}
}

func TestBedrockOpenAIChannelMetadata(t *testing.T) {
apiType, ok := ChannelType2APIType(constant.ChannelTypeAwsOpenAI)
require.True(t, ok)
require.Equal(t, constant.APITypeOpenAI, apiType)

require.Equal(t,
[]constant.EndpointType{constant.EndpointTypeOpenAIResponse},
GetEndpointTypesByChannelType(constant.ChannelTypeAwsOpenAI, "openai.gpt-5.4"),
)
require.Equal(t,
[]constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse},
GetEndpointTypesByChannelType(constant.ChannelTypeAwsOpenAI, "openai.gpt-oss-120b"),
)
require.Equal(t,
[]constant.EndpointType{constant.EndpointTypeOpenAI},
GetEndpointTypesByChannelType(constant.ChannelTypeAwsOpenAI, "openai.gpt-oss-120b-1:0"),
)
require.Equal(t,
[]constant.EndpointType{constant.EndpointTypeOpenAI},
GetEndpointTypesByChannelType(constant.ChannelTypeAwsOpenAI, "openai.gpt-oss-safeguard-120b"),
)
}
8 changes: 8 additions & 0 deletions common/endpoint_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeXai:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
case constant.ChannelTypeAwsOpenAI:
if IsBedrockOpenAIResponsesModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
} else if IsBedrockOpenAIMantleChatModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
} else {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
Expand Down
57 changes: 57 additions & 0 deletions common/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,65 @@ var (
"o4",
"chatgpt",
}
// BedrockOpenAIResponsesModels use the model-specific /openai/v1/responses
// path on the Bedrock Mantle endpoint and do not support Chat Completions.
BedrockOpenAIResponsesModels = []string{
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
"openai.gpt-5.5",
"openai.gpt-5.4",
}
// BedrockOpenAIMantleChatModels support both Chat Completions and Responses
// through the standard /v1 path on the Bedrock Mantle endpoint.
BedrockOpenAIMantleChatModels = []string{
"openai.gpt-oss-120b",
"openai.gpt-oss-20b",
}
// BedrockOpenAIChatOnlyModels support Chat Completions through both the
// Bedrock Mantle and Runtime endpoints.
BedrockOpenAIChatOnlyModels = []string{
"openai.gpt-oss-safeguard-120b",
"openai.gpt-oss-safeguard-20b",
}
// BedrockOpenAIRuntimeChatModels use the OpenAI-compatible Chat Completions
// endpoint exposed by bedrock-runtime.
BedrockOpenAIRuntimeChatModels = []string{
"openai.gpt-oss-120b-1:0",
"openai.gpt-oss-20b-1:0",
}
)

func modelNameInList(modelName string, models []string) bool {
for _, model := range models {
if strings.EqualFold(modelName, model) {
return true
}
}
return false
}

func normalizeBedrockOpenAIModelName(modelName string) string {
for _, suffix := range []string{"-xhigh", "-medium", "-minimal", "-high", "-low", "-none"} {
if strings.HasSuffix(modelName, suffix) {
return strings.TrimSuffix(modelName, suffix)
}
}
return modelName
}

func IsBedrockOpenAIResponsesModel(modelName string) bool {
return modelNameInList(normalizeBedrockOpenAIModelName(modelName), BedrockOpenAIResponsesModels)
}

func IsBedrockOpenAIMantleChatModel(modelName string) bool {
return modelNameInList(normalizeBedrockOpenAIModelName(modelName), BedrockOpenAIMantleChatModels)
}

func IsBedrockOpenAIRuntimeChatModel(modelName string) bool {
return modelNameInList(normalizeBedrockOpenAIModelName(modelName), BedrockOpenAIRuntimeChatModels)
}

func IsOpenAIResponseOnlyModel(modelName string) bool {
for _, m := range OpenAIResponseOnlyModels {
if strings.Contains(modelName, m) {
Expand Down
3 changes: 3 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const (
ChannelTypeReplicate = 56
ChannelTypeCodex = 57
ChannelTypeAdvancedCustom = 58
ChannelTypeAwsOpenAI = 59
ChannelTypeDummy // this one is only for count, do not add any channel after this

)
Expand Down Expand Up @@ -120,6 +121,7 @@ var ChannelBaseURLs = []string{
"https://api.replicate.com", //56
"https://chatgpt.com", //57
"", //58
"", //59
}

var ChannelTypeNames = map[int]string{
Expand Down Expand Up @@ -178,6 +180,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
ChannelTypeAdvancedCustom: "Advanced Custom",
ChannelTypeAwsOpenAI: "AWS OpenAI",
}

func GetChannelTypeName(channelType int) string {
Expand Down
16 changes: 16 additions & 0 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
if channel != nil && channel.Type == constant.ChannelTypeCodex {
return string(constant.EndpointTypeOpenAIResponse)
}
if channel != nil && channel.Type == constant.ChannelTypeAwsOpenAI {
upstreamModelName := modelName
modelMapping := normalizeChannelModelMapping(channel)
visited := map[string]bool{upstreamModelName: true}
for {
mappedModelName, ok := modelMapping[upstreamModelName]
if !ok || visited[mappedModelName] {
break
}
visited[mappedModelName] = true
upstreamModelName = mappedModelName
}
if common.IsBedrockOpenAIResponsesModel(upstreamModelName) {
return string(constant.EndpointTypeOpenAIResponse)
}
}
return normalized
}

Expand Down
22 changes: 22 additions & 0 deletions controller/channel_test_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,28 @@ func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
require.Equal(t, 2, userID)
}

func TestNormalizeChannelTestEndpointForBedrockOpenAI(t *testing.T) {
modelMapping := `{"bedrock-frontier":"openai.gpt-5.5"}`
channel := &model.Channel{
Type: constant.ChannelTypeAwsOpenAI,
ModelMapping: &modelMapping,
}

require.Equal(t,
string(constant.EndpointTypeOpenAIResponse),
normalizeChannelTestEndpoint(channel, "openai.gpt-5.5", ""),
)
require.Empty(t, normalizeChannelTestEndpoint(channel, "openai.gpt-oss-120b", ""))
require.Equal(t,
string(constant.EndpointTypeOpenAIResponse),
normalizeChannelTestEndpoint(channel, "bedrock-frontier", ""),
)
require.Equal(t,
string(constant.EndpointTypeOpenAI),
normalizeChannelTestEndpoint(channel, "openai.gpt-5.5", string(constant.EndpointTypeOpenAI)),
)
}

func TestSelectChannelsForAutomaticTestPassiveRecoveryOnlyUsesAutoDisabled(t *testing.T) {
channels := []*model.Channel{
{Id: 1, Status: common.ChannelStatusEnabled},
Expand Down
9 changes: 9 additions & 0 deletions controller/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
Expand Down Expand Up @@ -80,6 +81,14 @@ func init() {
OwnedBy: minimax.ChannelName,
})
}
for _, modelName := range openai.BedrockOpenAIModelList {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Object: "model",
Created: 1626777600,
OwnedBy: openai.BedrockOpenAIChannelName,
})
}
for modelName, _ := range constant.MidjourneyModel2Action {
openAIModels = append(openAIModels, dto.OpenAIModels{
Id: modelName,
Expand Down
11 changes: 11 additions & 0 deletions controller/model_owned_by_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ func TestChannelOwnerNameUsesAdaptorChannelName(t *testing.T) {
channelType: constant.ChannelTypeOpenRouter,
expected: "openrouter",
},
{
name: "bedrock openai",
channelType: constant.ChannelTypeAwsOpenAI,
expected: "AWS OpenAI",
},
{
name: "azure fallback",
channelType: constant.ChannelTypeAzure,
Expand All @@ -57,6 +62,12 @@ func TestBuildOpenAIModelFallsBackToCustomForUnknownModels(t *testing.T) {
require.Equal(t, "custom", modelItem.OwnedBy)
}

func TestBedrockOpenAIModelsAreRegistered(t *testing.T) {
modelItem, ok := openAIModelsMap["openai.gpt-5.6-sol"]
require.True(t, ok)
require.Equal(t, "AWS OpenAI", modelItem.OwnedBy)
}

func TestGetModelListGroupsUsesUserGroupWhenTokenGroupIsEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
Expand Down
7 changes: 7 additions & 0 deletions relay/channel/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ type Adaptor interface {
ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error)
}

// RequestSigner signs a fully constructed outbound HTTP request after header
// overrides have been applied. Adaptors that use request-bound authentication,
// such as AWS SigV4, can implement this optional interface.
type RequestSigner interface {
SignRequest(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error
}

type TaskAdaptor interface {
Init(info *relaycommon.RelayInfo)

Expand Down
17 changes: 17 additions & 0 deletions relay/channel/api_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ func applyHeaderOverrideToRequest(req *http.Request, headerOverride map[string]s
}
}

func signRequestIfNeeded(a Adaptor, c *gin.Context, req *http.Request, info *common.RelayInfo) error {
signer, ok := a.(RequestSigner)
if !ok {
return nil
}
if err := signer.SignRequest(c, req, info); err != nil {
return fmt.Errorf("sign request failed: %w", err)
}
return nil
}

func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
fullRequestURL, err := a.GetRequestURL(info)
if err != nil {
Expand All @@ -327,6 +338,9 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
if err = signRequestIfNeeded(a, c, req, info); err != nil {
return nil, err
}
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
Expand Down Expand Up @@ -359,6 +373,9 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
if err = signRequestIfNeeded(a, c, req, info); err != nil {
return nil, err
}
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
Expand Down
20 changes: 19 additions & 1 deletion relay/channel/openai/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
}
switch info.ChannelType {
case constant.ChannelTypeAwsOpenAI:
return getBedrockOpenAIRequestURL(info)
case constant.ChannelTypeAzure:
apiVersion := info.ApiVersion
if apiVersion == "" {
Expand Down Expand Up @@ -182,6 +184,16 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {

func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, header)
if info.ChannelType == constant.ChannelTypeAwsOpenAI {
credentials, err := parseBedrockOpenAICredentials(info)
if err != nil {
return err
}
if credentials.keyType == dto.AwsKeyTypeApiKey {
header.Set("Authorization", "Bearer "+credentials.apiKey)
}
return nil
}
if info.ChannelType == constant.ChannelTypeAzure {
header.Set("api-key", info.ApiKey)
return nil
Expand Down Expand Up @@ -245,7 +257,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure {
if info.ChannelType != constant.ChannelTypeOpenAI &&
info.ChannelType != constant.ChannelTypeAzure &&
info.ChannelType != constant.ChannelTypeAwsOpenAI {
request.StreamOptions = nil
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
Expand Down Expand Up @@ -680,6 +694,8 @@ func (a *Adaptor) GetModelList() []string {
return xinference.ModelList
case constant.ChannelTypeOpenRouter:
return openrouter.ModelList
case constant.ChannelTypeAwsOpenAI:
return BedrockOpenAIModelList
default:
return ModelList
}
Expand All @@ -697,6 +713,8 @@ func (a *Adaptor) GetChannelName() string {
return xinference.ChannelName
case constant.ChannelTypeOpenRouter:
return openrouter.ChannelName
case constant.ChannelTypeAwsOpenAI:
return BedrockOpenAIChannelName
default:
return ChannelName
}
Expand Down
Loading