Skip to content
Draft
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
12 changes: 7 additions & 5 deletions relay/channel/aws/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ const (
)

type Adaptor struct {
ClientMode ClientMode
AwsClient *bedrockruntime.Client
AwsModelId string
AwsReq any
IsNova bool
ClientMode ClientMode
AwsClient *bedrockruntime.Client
AwsModelId string
AwsReq any
anthropicWorkspaceID string
IsNova bool
}

func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
Expand Down Expand Up @@ -105,6 +106,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {

func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
claude.CommonClaudeHeadersOperation(c, req, info)
claude.ForwardAnthropicWorkspaceIDHeader(c, req, info)
if a.ClientMode == ClientModeApiKey {
req.Set("Authorization", "Bearer "+info.ApiKey)
}
Expand Down
20 changes: 17 additions & 3 deletions relay/channel/aws/relay-aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types"
"github.com/aws/smithy-go/auth/bearer"
smithyhttp "github.com/aws/smithy-go/transport/http"
)

// getAwsErrorStatusCode extracts HTTP status code from AWS SDK error
Expand Down Expand Up @@ -119,6 +120,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
for key, value := range headerOverride {
requestHeader.Set(key, value)
}
a.anthropicWorkspaceID = ""

if isNovaModel(awsModelId) {
var novaReq *NovaRequest
Expand All @@ -142,6 +144,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
a.AwsReq = awsReq
return nil, nil
} else {
a.anthropicWorkspaceID = requestHeader.Get(claude.AnthropicWorkspaceIDHeader)
awsClaudeReq, err := formatRequest(requestBody, requestHeader)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "format aws request fail"), types.ErrorCodeBadRequestBody)
Expand Down Expand Up @@ -175,6 +178,17 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
}
}

func (a *Adaptor) invokeOptions() []func(*bedrockruntime.Options) {
if a.anthropicWorkspaceID == "" {
return nil
}
return []func(*bedrockruntime.Options){
bedrockruntime.WithAPIOptions(
smithyhttp.SetHeaderValue(claude.AnthropicWorkspaceIDHeader, a.anthropicWorkspaceID),
),
}
}

// buildAwsRequestBody prepares the payload for AWS requests, applying passthrough rules when enabled.
func buildAwsRequestBody(c *gin.Context, info *relaycommon.RelayInfo, awsClaudeReq any) ([]byte, error) {
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
Expand Down Expand Up @@ -232,7 +246,7 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types
ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()

awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput))
awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput), a.invokeOptions()...)
if err != nil {
return newAwsInvokeError(requestContext, err, "InvokeModel"), nil
}
Expand Down Expand Up @@ -262,7 +276,7 @@ func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (
ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()

awsResp, err := a.AwsClient.InvokeModelWithResponseStream(ctx, a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput))
awsResp, err := a.AwsClient.InvokeModelWithResponseStream(ctx, a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput), a.invokeOptions()...)
if err != nil {
return newAwsInvokeError(requestContext, err, "InvokeModelWithResponseStream"), nil
}
Expand Down Expand Up @@ -320,7 +334,7 @@ func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor)
ctx, cancel := newAwsInvokeContext(requestContext)
defer cancel()

awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput))
awsResp, err := a.AwsClient.InvokeModel(ctx, a.AwsReq.(*bedrockruntime.InvokeModelInput), a.invokeOptions()...)
if err != nil {
return newAwsInvokeError(requestContext, err, "InvokeModel"), nil
}
Expand Down
135 changes: 135 additions & 0 deletions relay/channel/aws/relay_aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relay/channel/claude"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
Expand Down Expand Up @@ -141,6 +142,34 @@ func newAwsStreamResponse(request *http.Request, body io.ReadCloser) *http.Respo
}
}

func TestAPIKeySetupRequestHeaderForwardsWorkspaceID(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set(claude.AnthropicWorkspaceIDHeader, "wrkspc_client")

info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-5-sonnet-20240620",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "bedrock-api-key|us-east-1",
UpstreamModelName: "claude-3-5-sonnet-20240620",
ChannelOtherSettings: dto.ChannelOtherSettings{
AwsKeyType: dto.AwsKeyTypeApiKey,
AnthropicWorkspaceID: " proj_configured ",
},
},
}

adaptor := &Adaptor{}
_, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
require.Equal(t, ClientModeApiKey, adaptor.ClientMode)

header := http.Header{}
require.NoError(t, adaptor.SetupRequestHeader(ctx, &header, info))
require.Equal(t, "proj_configured", header.Get(claude.AnthropicWorkspaceIDHeader))
}

func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -182,6 +211,112 @@ func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testi
require.Equal(t, []any{"computer-use-2025-01-24"}, values)
}

func TestDoAwsClientRequest_AppliesWorkspaceIDHeaderOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set(claude.AnthropicWorkspaceIDHeader, "wrkspc_client")

info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-5-sonnet-20240620",
UseRuntimeHeadersOverride: true,
RuntimeHeadersOverride: map[string]any{
claude.AnthropicWorkspaceIDHeader: "wrkspc_admin",
},
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "access-key|secret-key|us-east-1",
UpstreamModelName: "claude-3-5-sonnet-20240620",
ChannelOtherSettings: dto.ChannelOtherSettings{
AnthropicWorkspaceID: "proj_configured",
},
},
}

requestBody := bytes.NewBufferString(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`)
adaptor := &Adaptor{}

_, err := doAwsClientRequest(ctx, info, adaptor, requestBody)
require.NoError(t, err)
require.Equal(t, "wrkspc_admin", adaptor.anthropicWorkspaceID)
}

func TestDoAwsClientRequest_UsesConfiguredWorkspaceID(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set(claude.AnthropicWorkspaceIDHeader, "wrkspc_client")

info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-5-sonnet-20240620",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "access-key|secret-key|us-east-1",
UpstreamModelName: "claude-3-5-sonnet-20240620",
ChannelOtherSettings: dto.ChannelOtherSettings{
AnthropicWorkspaceID: " proj_configured ",
},
},
}

requestBody := bytes.NewBufferString(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`)
adaptor := &Adaptor{}

_, err := doAwsClientRequest(ctx, info, adaptor, requestBody)
require.NoError(t, err)
require.Equal(t, "proj_configured", adaptor.anthropicWorkspaceID)
}

func TestAwsInvokeOptionsSendSignedWorkspaceID(t *testing.T) {
const workspaceID = "proj_abc123"

tests := []struct {
name string
invoke func(*bedrockruntime.Client, ...func(*bedrockruntime.Options)) error
}{
{
name: "invoke model",
invoke: func(client *bedrockruntime.Client, options ...func(*bedrockruntime.Options)) error {
_, err := client.InvokeModel(context.Background(), newAwsInvokeModelInput(), options...)
return err
},
},
{
name: "invoke model with response stream",
invoke: func(client *bedrockruntime.Client, options ...func(*bedrockruntime.Options)) error {
response, err := client.InvokeModelWithResponseStream(context.Background(), newAwsStreamInput(), options...)
if err != nil {
return err
}
return response.GetStream().Close()
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) {
require.Equal(t, workspaceID, request.Header.Get(claude.AnthropicWorkspaceIDHeader))
require.Contains(t, request.Header.Get("Authorization"), claude.AnthropicWorkspaceIDHeader)

if request.Header.Get("Accept") == "application/vnd.amazon.eventstream" {
return newAwsStreamResponse(request, io.NopCloser(bytes.NewReader(nil))), nil
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
Request: request,
}, nil
}))
adaptor := &Adaptor{anthropicWorkspaceID: workspaceID}

require.NoError(t, test.invoke(client, adaptor.invokeOptions()...))
})
}
}

func TestNewAwsInvokeContextInheritsParent(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
t.Cleanup(func() {
Expand Down
20 changes: 20 additions & 0 deletions relay/channel/claude/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"strings"

"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
Expand Down Expand Up @@ -80,6 +81,24 @@ func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relayc
model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req)
}

// ForwardAnthropicWorkspaceIDHeader applies the configured workspace ID, falling
// back to the incoming request header. Generic header overrides are applied later.
func ForwardAnthropicWorkspaceIDHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) {
workspaceID := ""
if c != nil && c.Request != nil {
workspaceID = strings.TrimSpace(c.Request.Header.Get(AnthropicWorkspaceIDHeader))
}
if info != nil {
configuredWorkspaceID := strings.TrimSpace(info.ChannelOtherSettings.AnthropicWorkspaceID)
if configuredWorkspaceID != "" {
workspaceID = configuredWorkspaceID
}
}
if workspaceID != "" {
req.Set(AnthropicWorkspaceIDHeader, workspaceID)
}
}

func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("x-api-key", info.ApiKey)
Expand All @@ -89,6 +108,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
}
req.Set("anthropic-version", anthropicVersion)
CommonClaudeHeadersOperation(c, req, info)
ForwardAnthropicWorkspaceIDHeader(c, req, info)
return nil
}

Expand Down
84 changes: 84 additions & 0 deletions relay/channel/claude/adaptor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package claude

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"

relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func TestAdaptorForwardsAnthropicWorkspaceID(t *testing.T) {
gin.SetMode(gin.TestMode)

tests := []struct {
name string
configuredWorkspaceID string
headerOverride map[string]any
expected string
}{
{
name: "incoming workspace",
expected: "wrkspc_client",
},
{
name: "configured workspace wins",
configuredWorkspaceID: " proj_configured ",
expected: "proj_configured",
},
{
name: "channel override wins",
configuredWorkspaceID: "proj_configured",
headerOverride: map[string]any{
AnthropicWorkspaceIDHeader: "wrkspc_admin",
},
expected: "wrkspc_admin",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
workspaceIDs := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
workspaceIDs <- request.Header.Get(AnthropicWorkspaceIDHeader)
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{}`))
}))
t.Cleanup(server.Close)

ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Request.Header.Set(AnthropicWorkspaceIDHeader, "wrkspc_client")

info := &relaycommon.RelayInfo{
OriginModelName: "claude-sonnet-4-6",
ChannelMeta: &relaycommon.ChannelMeta{
ApiKey: "upstream-key",
ChannelBaseUrl: server.URL,
HeadersOverride: test.headerOverride,
UpstreamModelName: "claude-sonnet-4-6",
ChannelOtherSettings: dto.ChannelOtherSettings{
AnthropicWorkspaceID: test.configuredWorkspaceID,
},
},
}

result, err := (&Adaptor{}).DoRequest(ctx, info, bytes.NewBufferString(`{"messages":[]}`))
require.NoError(t, err)
response, ok := result.(*http.Response)
require.True(t, ok)
t.Cleanup(func() {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
})
require.Equal(t, test.expected, <-workspaceIDs)
})
}
}
3 changes: 3 additions & 0 deletions relay/channel/claude/constants.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package claude

// AnthropicWorkspaceIDHeader selects an Anthropic workspace or Bedrock Mantle project.
const AnthropicWorkspaceIDHeader = "anthropic-workspace-id"

var ModelList = []string{
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
Expand Down
1 change: 1 addition & 0 deletions relaykit/dto/channel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
AnthropicWorkspaceID string `json:"anthropic_workspace_id,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
Expand Down
Loading
Loading