package aws import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" relaytypes "github.com/QuantumNous/new-api/relaykit/types" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi" "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const awsTestModel = "anthropic.claude-3-5-sonnet-20240620-v1:0" type awsHTTPClientFunc func(*http.Request) (*http.Response, error) func (f awsHTTPClientFunc) Do(request *http.Request) (*http.Response, error) { return f(request) } type awsNotifyingResponseWriter struct { *httptest.ResponseRecorder notifyOn []byte notified chan int once sync.Once } func newAwsNotifyingResponseWriter(notifyOn string) *awsNotifyingResponseWriter { return &awsNotifyingResponseWriter{ ResponseRecorder: httptest.NewRecorder(), notifyOn: []byte(notifyOn), notified: make(chan int, 1), } } func (w *awsNotifyingResponseWriter) Write(data []byte) (int, error) { return w.ResponseRecorder.Write(data) } func (w *awsNotifyingResponseWriter) Flush() { w.ResponseRecorder.Flush() if bytes.Contains(w.Body.Bytes(), w.notifyOn) { w.once.Do(func() { w.notified <- w.Body.Len() }) } } func newAwsTestClient(httpClient bedrockruntime.HTTPClient) *bedrockruntime.Client { return bedrockruntime.New(bedrockruntime.Options{ Region: "us-east-1", BaseEndpoint: aws.String("https://bedrock.test"), Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider( "access-key", "secret-key", "", )), HTTPClient: httpClient, Retryer: aws.NopRetryer{}, }) } func newAwsTestContext(writer http.ResponseWriter, requestContext context.Context) *gin.Context { c, _ := gin.CreateTestContext(writer) c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(requestContext) return c } func newAwsTestRelayInfo() *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ StartTime: time.Now(), IsStream: true, OriginModelName: awsTestModel, RelayFormat: relaytypes.RelayFormatOpenAI, ShouldIncludeUsage: true, ChannelMeta: &relaycommon.ChannelMeta{ UpstreamModelName: awsTestModel, }, } } func newAwsInvokeModelInput() *bedrockruntime.InvokeModelInput { return &bedrockruntime.InvokeModelInput{ ModelId: aws.String(awsTestModel), Body: []byte(`{}`), Accept: aws.String("application/json"), ContentType: aws.String("application/json"), } } func newAwsStreamInput() *bedrockruntime.InvokeModelWithResponseStreamInput { return &bedrockruntime.InvokeModelWithResponseStreamInput{ ModelId: aws.String(awsTestModel), Body: []byte(`{}`), Accept: aws.String("application/json"), ContentType: aws.String("application/json"), } } func writeAwsStreamEvent(writer io.Writer, data string) error { payload, err := common.Marshal(struct { Bytes []byte `json:"bytes"` }{Bytes: []byte(data)}) if err != nil { return err } return eventstream.NewEncoder().Encode(writer, eventstream.Message{ Headers: eventstream.Headers{ {Name: eventstreamapi.MessageTypeHeader, Value: eventstream.StringValue(eventstreamapi.EventMessageType)}, {Name: eventstreamapi.EventTypeHeader, Value: eventstream.StringValue("chunk")}, {Name: eventstreamapi.ContentTypeHeader, Value: eventstream.StringValue("application/json")}, }, Payload: payload, }) } func newAwsStreamResponse(request *http.Request, body io.ReadCloser) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Header: http.Header{ "Content-Type": []string{"application/vnd.amazon.eventstream"}, "X-Amzn-Bedrock-Content-Type": []string{"application/json"}, }, Body: body, Request: request, } } func TestDoAwsClientRequest_AppliesRuntimeHeaderOverrideToAnthropicBeta(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) info := &relaycommon.RelayInfo{ OriginModelName: "claude-3-5-sonnet-20240620", IsStream: false, UseRuntimeHeadersOverride: true, RuntimeHeadersOverride: map[string]any{ "anthropic-beta": "computer-use-2025-01-24", }, ChannelMeta: &relaycommon.ChannelMeta{ ApiKey: "access-key|secret-key|us-east-1", UpstreamModelName: "claude-3-5-sonnet-20240620", }, } requestBody := bytes.NewBufferString(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`) adaptor := &Adaptor{} _, err := doAwsClientRequest(ctx, info, adaptor, requestBody) require.NoError(t, err) awsReq, ok := adaptor.AwsReq.(*bedrockruntime.InvokeModelInput) require.True(t, ok) var payload map[string]any require.NoError(t, common.Unmarshal(awsReq.Body, &payload)) anthropicBeta, exists := payload["anthropic_beta"] require.True(t, exists) values, ok := anthropicBeta.([]any) require.True(t, ok) require.Equal(t, []any{"computer-use-2025-01-24"}, values) } func TestDoAwsClientRequest_ForwardsThinkingBlockBindingAndBetaList(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("anthropic-beta", "fast-mode-2026-02-01, thinking-binding-controls-2026-08-01") info := &relaycommon.RelayInfo{ OriginModelName: "claude-opus-5-5", ChannelMeta: &relaycommon.ChannelMeta{ ApiKey: "access-key|secret-key|us-east-1", UpstreamModelName: "claude-opus-5-5", }, } requestBody := bytes.NewBufferString(`{"max_tokens":128,"messages":[{"role":"user","content":"hello"}],` + `"thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`) adaptor := &Adaptor{} _, err := doAwsClientRequest(ctx, info, adaptor, requestBody) require.NoError(t, err) awsReq, ok := adaptor.AwsReq.(*bedrockruntime.InvokeModelInput) require.True(t, ok) var payload struct { AnthropicBeta []string `json:"anthropic_beta"` Thinking json.RawMessage `json:"thinking"` } require.NoError(t, common.Unmarshal(awsReq.Body, &payload)) assert.Equal(t, []string{"fast-mode-2026-02-01", "thinking-binding-controls-2026-08-01"}, payload.AnthropicBeta) assert.JSONEq(t, `{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}`, string(payload.Thinking)) } func TestNewAwsInvokeContextInheritsParent(t *testing.T) { originalRelayTimeout := common.RelayTimeout t.Cleanup(func() { common.RelayTimeout = originalRelayTimeout }) tests := []struct { name string relayTimeout int wantDeadline bool }{ {name: "without relay timeout", relayTimeout: 0, wantDeadline: false}, {name: "with relay timeout", relayTimeout: 30, wantDeadline: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { common.RelayTimeout = test.relayTimeout parent, cancelParent := context.WithCancel(context.Background()) invokeContext, cancelInvoke := newAwsInvokeContext(parent) defer cancelInvoke() _, hasDeadline := invokeContext.Deadline() assert.Equal(t, test.wantDeadline, hasDeadline) cancelParent() require.ErrorIs(t, invokeContext.Err(), context.Canceled) }) } } func TestNewAwsInvokeErrorSkipsRetryOnlyForClientCancellation(t *testing.T) { canceledContext, cancel := context.WithCancel(context.Background()) cancel() tests := []struct { name string requestContext context.Context err error wantSkipRetry bool }{ { name: "client context canceled", requestContext: canceledContext, err: context.Canceled, wantSkipRetry: true, }, { name: "relay timeout with live client context", requestContext: context.Background(), err: context.DeadlineExceeded, wantSkipRetry: false, }, { name: "upstream error with live client context", requestContext: context.Background(), err: errors.New("upstream failed"), wantSkipRetry: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { err := newAwsInvokeError(test.requestContext, test.err, "InvokeModel") assert.Equal(t, test.wantSkipRetry, relaytypes.IsSkipRetryError(err)) }) } } func TestAwsHandlersCancelSdkRequestAndSkipRetry(t *testing.T) { originalRelayTimeout := common.RelayTimeout common.RelayTimeout = 0 t.Cleanup(func() { common.RelayTimeout = originalRelayTimeout }) tests := []struct { name string request any handle func(*gin.Context, *relaycommon.RelayInfo, *Adaptor) (*relaytypes.NewAPIError, *dto.Usage) }{ {name: "non-stream", request: newAwsInvokeModelInput(), handle: awsHandler}, {name: "stream", request: newAwsStreamInput(), handle: awsStreamHandler}, {name: "nova", request: newAwsInvokeModelInput(), handle: handleNovaRequest}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { requestContext, cancelRequest := context.WithCancel(context.Background()) t.Cleanup(cancelRequest) upstreamContexts := make(chan context.Context, 1) client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) { upstreamContexts <- request.Context() <-request.Context().Done() return nil, request.Context().Err() })) adaptor := &Adaptor{AwsClient: client, AwsReq: test.request} c := newAwsTestContext(httptest.NewRecorder(), requestContext) info := newAwsTestRelayInfo() type handlerResult struct { err *relaytypes.NewAPIError usage *dto.Usage } results := make(chan handlerResult, 1) go func() { err, usage := test.handle(c, info, adaptor) results <- handlerResult{err: err, usage: usage} }() var upstreamContext context.Context select { case upstreamContext = <-upstreamContexts: case result := <-results: t.Fatalf("handler returned before issuing AWS request: %v", result.err) case <-time.After(5 * time.Second): t.Fatal("AWS request did not start") } cancelRequest() var result handlerResult select { case result = <-results: case <-time.After(5 * time.Second): t.Fatal("handler did not stop after client cancellation") } require.ErrorIs(t, upstreamContext.Err(), context.Canceled) require.NotNil(t, result.err) assert.True(t, relaytypes.IsSkipRetryError(result.err)) assert.Nil(t, result.usage) }) } } func TestAwsStreamHandlerUsesFinalUpstreamUsage(t *testing.T) { originalRelayTimeout := common.RelayTimeout common.RelayTimeout = 0 t.Cleanup(func() { common.RelayTimeout = originalRelayTimeout }) events := []string{ `{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":100,"output_tokens":1}}}`, `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}`, `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":423}}`, `{"type":"message_stop"}`, } client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) { var body bytes.Buffer for _, event := range events { if err := writeAwsStreamEvent(&body, event); err != nil { return nil, err } } return newAwsStreamResponse(request, io.NopCloser(bytes.NewReader(body.Bytes()))), nil })) adaptor := &Adaptor{AwsClient: client, AwsReq: newAwsStreamInput()} recorder := httptest.NewRecorder() c := newAwsTestContext(recorder, context.Background()) handlerErr, usage := awsStreamHandler(c, newAwsTestRelayInfo(), adaptor) require.Nil(t, handlerErr) require.NotNil(t, usage) require.NotNil(t, usage.BillingUsage) require.NotNil(t, usage.BillingUsage.ClaudeUsage) assert.Equal(t, 100, usage.BillingUsage.ClaudeUsage.InputTokens) assert.Equal(t, 423, usage.BillingUsage.ClaudeUsage.OutputTokens) assert.Contains(t, recorder.Body.String(), "[DONE]") } func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) { originalRelayTimeout := common.RelayTimeout common.RelayTimeout = 0 t.Cleanup(func() { common.RelayTimeout = originalRelayTimeout }) requestContext, cancelRequest := context.WithCancel(context.Background()) t.Cleanup(cancelRequest) releaseFinal := make(chan struct{}) var releaseFinalOnce sync.Once release := func() { releaseFinalOnce.Do(func() { close(releaseFinal) }) } t.Cleanup(release) producerResults := make(chan error, 1) upstreamContexts := make(chan context.Context, 1) client := newAwsTestClient(awsHTTPClientFunc(func(request *http.Request) (*http.Response, error) { upstreamContexts <- request.Context() reader, writer := io.Pipe() go func() { defer writer.Close() initialEvents := []string{ `{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-test","content":[],"usage":{"input_tokens":100,"output_tokens":1}}}`, `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}`, } for _, event := range initialEvents { if err := writeAwsStreamEvent(writer, event); err != nil { producerResults <- err return } } <-releaseFinal producerResults <- writeAwsStreamEvent(writer, `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":423}}`) }() return newAwsStreamResponse(request, reader), nil })) responseWriter := newAwsNotifyingResponseWriter("partial") c := newAwsTestContext(responseWriter, requestContext) adaptor := &Adaptor{AwsClient: client, AwsReq: newAwsStreamInput()} type handlerResult struct { err *relaytypes.NewAPIError usage *dto.Usage } results := make(chan handlerResult, 1) go func() { err, usage := awsStreamHandler(c, newAwsTestRelayInfo(), adaptor) results <- handlerResult{err: err, usage: usage} }() var upstreamContext context.Context select { case upstreamContext = <-upstreamContexts: case <-time.After(5 * time.Second): t.Fatal("AWS stream request did not start") } var bodyLengthBeforeCancel int select { case bodyLengthBeforeCancel = <-responseWriter.notified: case <-time.After(5 * time.Second): t.Fatal("partial response was not written") } cancelRequest() var result handlerResult select { case result = <-results: case <-time.After(5 * time.Second): t.Fatal("stream handler did not stop after client cancellation") } require.ErrorIs(t, upstreamContext.Err(), context.Canceled) require.Nil(t, result.err) require.NotNil(t, result.usage) assert.Equal(t, bodyLengthBeforeCancel, responseWriter.Body.Len()) assert.NotContains(t, responseWriter.Body.String(), "[DONE]") release() select { case producerErr := <-producerResults: require.Error(t, producerErr) case <-time.After(5 * time.Second): t.Fatal("upstream producer did not observe the closed stream") } } func newMantleTestInfo() *relaycommon.RelayInfo { info := newAwsTestRelayInfo() info.IsStream = false info.RelayFormat = relaytypes.RelayFormatClaude info.UpstreamModelName = "claude-opus-4-7" info.ApiKey = "test-api-key|us-east-1" info.ChannelOtherSettings.AwsApiType = dto.AwsApiTypeMantle return info } func TestMantleRequestPreservesNativeFieldsAndSignsFinalPayload(t *testing.T) { for _, key := range []string{"test-api-key|us-east-1", "access-key|secret-key|eu-west-1"} { t.Run(strings.Split(key, "|")[0], func(t *testing.T) { info := newMantleTestInfo() info.ApiKey = key info.UseRuntimeHeadersOverride = true info.RuntimeHeadersOverride = map[string]any{"anthropic-beta": "test-beta", "X-Test": "override"} info.ChannelSetting.PassThroughBodyEnabled = true ctx, cancel := context.WithCancel(context.Background()) defer cancel() c := newAwsTestContext(httptest.NewRecorder(), ctx) c.Request.Header.Set("Authorization", "Bearer client-secret") c.Request.Header.Set("x-api-key", "client-secret") a := &Adaptor{} body := `{"model":"client-alias","stream":true,"max_tokens":128,"temperature":0,"cache_control":{"type":"ephemeral","ttl":"1h"},"messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}],"future_option":{"enabled":false},"anthropic_version":"bedrock-2023-05-31"}` req, err := a.newMantleRequest(c, info, strings.NewReader(body)) require.NoError(t, err) assert.Equal(t, "/anthropic/v1/messages", req.URL.Path) assert.Equal(t, http.MethodPost, req.Method) assert.Equal(t, "2023-06-01", req.Header.Get("anthropic-version")) assert.Equal(t, "test-beta", req.Header.Get("anthropic-beta")) assert.Equal(t, "override", req.Header.Get("X-Test")) payload, err := io.ReadAll(req.Body) require.NoError(t, err) var data map[string]json.RawMessage require.NoError(t, common.Unmarshal(payload, &data)) assert.JSONEq(t, `"anthropic.claude-opus-4-7"`, string(data["model"])) assert.JSONEq(t, `{"type":"ephemeral","ttl":"1h"}`, string(data["cache_control"])) assert.JSONEq(t, `[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral"}}]}]`, string(data["messages"])) assert.JSONEq(t, `{"enabled":false}`, string(data["future_option"])) assert.Equal(t, "0", string(data["temperature"])) assert.Equal(t, "true", string(data["stream"])) assert.NotContains(t, data, "anthropic_version") replay, err := req.GetBody() require.NoError(t, err) defer replay.Close() replayed, err := io.ReadAll(replay) require.NoError(t, err) assert.Equal(t, payload, replayed) if strings.HasPrefix(key, "access-key|") { assert.Empty(t, req.Header.Get("x-api-key")) auth := req.Header.Get("Authorization") assert.Contains(t, auth, "/eu-west-1/bedrock-mantle/aws4_request") assert.NotContains(t, auth, "secret-key") signedAt, err := time.Parse("20060102T150405Z", req.Header.Get("X-Amz-Date")) require.NoError(t, err) verification := req.Clone(ctx) verification.Header.Del("Authorization") hash := sha256.Sum256(payload) err = v4.NewSigner().SignHTTP(ctx, aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "eu-west-1", signedAt) require.NoError(t, err) assert.Equal(t, auth, verification.Header.Get("Authorization")) } else { assert.Equal(t, "test-api-key", req.Header.Get("x-api-key")) assert.Empty(t, req.Header.Get("Authorization")) } cancel() assert.ErrorIs(t, req.Context().Err(), context.Canceled) }) } } func TestMantleEndpointAndCredentialValidation(t *testing.T) { for _, test := range []struct { name, key, base, want string invalid bool }{ {name: "regional default", key: "key|us-west-2", want: "https://bedrock-mantle.us-west-2.api.aws/anthropic/v1/messages"}, {name: "proxy root", key: "ak|sk|us-east-1", base: "https://proxy.example/gateway/", want: "https://proxy.example/gateway/anthropic/v1/messages"}, {name: "anthropic root", key: "key|us-east-1", base: "https://proxy.example/anthropic/", want: "https://proxy.example/anthropic/v1/messages"}, {name: "version root", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1", want: "https://proxy.example/anthropic/v1/messages"}, {name: "full endpoint", key: "key|us-east-1", base: "https://proxy.example/anthropic/v1/messages", want: "https://proxy.example/anthropic/v1/messages"}, {name: "missing region", key: "secret", invalid: true}, {name: "empty key", key: "|us-east-1", invalid: true}, {name: "empty secret", key: "ak||us-east-1", invalid: true}, {name: "injected host", key: "key|us-east-1.evil.example/", invalid: true}, {name: "plaintext URL", key: "key|us-east-1", base: "http://proxy.example", invalid: true}, {name: "URL credentials", key: "key|us-east-1", base: "https://user:secret@proxy.example", invalid: true}, } { t.Run(test.name, func(t *testing.T) { info := newMantleTestInfo() info.ApiKey, info.ChannelBaseUrl = test.key, test.base endpoint, err := (&Adaptor{}).GetRequestURL(info) if test.invalid { require.Error(t, err) assert.NotContains(t, err.Error(), "secret") assert.Empty(t, endpoint) return } require.NoError(t, err) assert.Equal(t, test.want, endpoint) }) } } func TestMantleHTTPResponsesPreserveCacheUsage(t *testing.T) { originalTimeout := constant.StreamingTimeout constant.StreamingTimeout = 30 t.Cleanup(func() { constant.StreamingTimeout = originalTimeout }) const usageJSON = `{"input_tokens":100,"output_tokens":23,"cache_read_input_tokens":1000,"cache_creation_input_tokens":50,"cache_creation":{"ephemeral_5m_input_tokens":20,"ephemeral_1h_input_tokens":30}}` for _, stream := range []bool{false, true} { for _, format := range []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAI} { t.Run(fmt.Sprintf("stream=%t/format=%s", stream, format), func(t *testing.T) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if stream { w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_mantle\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"anthropic.claude-opus-4-7\",\"content\":[],\"usage\":%s}}\n\n", usageJSON) fmt.Fprint(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n") fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":23}}\n\n") fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"id":"msg_mantle","type":"message","role":"assistant","model":"anthropic.claude-opus-4-7","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":%s}`, usageJSON) })) defer server.Close() original := http.DefaultClient http.DefaultClient = server.Client() defer func() { http.DefaultClient = original }() info := newMantleTestInfo() info.ChannelBaseUrl, info.IsStream, info.RelayFormat = server.URL, stream, format recorder := httptest.NewRecorder() c := newAwsTestContext(recorder, context.Background()) a := &Adaptor{} a.Init(info) var request dto.ClaudeRequest require.NoError(t, common.UnmarshalJsonStr(`{"model":"claude-opus-4-7","max_tokens":128,"messages":[{"role":"user","content":"hello"}],"cache_control":{"type":"ephemeral"}}`, &request)) request.Stream = &stream converted, err := a.ConvertClaudeRequest(c, info, &request) require.NoError(t, err) body, err := common.Marshal(converted) require.NoError(t, err) response, err := a.DoRequest(c, info, bytes.NewReader(body)) require.NoError(t, err) httpResponse, ok := response.(*http.Response) require.True(t, ok) result, apiErr := a.DoResponse(c, httpResponse, info) require.Nil(t, apiErr) usage, ok := result.(*dto.Usage) require.True(t, ok) require.NotNil(t, usage.BillingUsage) require.NotNil(t, usage.BillingUsage.ClaudeUsage) claudeUsage := usage.BillingUsage.ClaudeUsage assert.Equal(t, 100, claudeUsage.InputTokens) assert.Equal(t, 23, claudeUsage.OutputTokens) assert.Equal(t, 1000, claudeUsage.CacheReadInputTokens) assert.Equal(t, 50, claudeUsage.CacheCreationInputTokens) assert.Equal(t, 20, claudeUsage.GetCacheCreation5mTokens()) assert.Equal(t, 30, claudeUsage.GetCacheCreation1hTokens()) assert.Contains(t, recorder.Body.String(), "hello") if stream && format == relaytypes.RelayFormatOpenAI { assert.Contains(t, recorder.Body.String(), "[DONE]") } }) } } } func TestMantleDoesNotFollowRedirectsOrFallbackOnAuthFailure(t *testing.T) { for _, status := range []int{http.StatusTemporaryRedirect, http.StatusUnauthorized, http.StatusTooManyRequests} { t.Run(fmt.Sprint(status), func(t *testing.T) { calls := 0 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ w.Header().Set("Location", "/credential-leak") w.WriteHeader(status) })) defer server.Close() original := http.DefaultClient http.DefaultClient = server.Client() defer func() { http.DefaultClient = original }() info := newMantleTestInfo() info.ChannelBaseUrl = server.URL c := newAwsTestContext(httptest.NewRecorder(), context.Background()) response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[]}`)) require.NoError(t, err) httpResponse := response.(*http.Response) defer httpResponse.Body.Close() assert.Equal(t, status, httpResponse.StatusCode) assert.Equal(t, 1, calls) }) } } func TestAWSLegacyInterfaceRemainsDefault(t *testing.T) { for _, mode := range []dto.AwsApiType{"", dto.AwsApiTypeInvokeModel} { for _, stream := range []bool{false, true} { t.Run(fmt.Sprintf("%s/stream=%t", mode, stream), func(t *testing.T) { info := newMantleTestInfo() info.ChannelOtherSettings.AwsApiType = mode info.IsStream = stream info.UpstreamModelName = "claude-3-5-sonnet-20240620" c := newAwsTestContext(httptest.NewRecorder(), context.Background()) a := &Adaptor{} a.Init(info) response, err := a.DoRequest(c, info, strings.NewReader(`{"max_tokens":128,"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}],"cache_control":{"type":"ephemeral"}}`)) require.NoError(t, err) assert.Nil(t, response) var body []byte if stream { input, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput) require.True(t, ok) body = input.Body assert.Equal(t, "us."+awsTestModel, *input.ModelId) } else { input, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput) require.True(t, ok) body = input.Body assert.Equal(t, "us."+awsTestModel, *input.ModelId) } var payload map[string]json.RawMessage require.NoError(t, common.Unmarshal(body, &payload)) assert.Equal(t, `"bedrock-2023-05-31"`, string(payload["anthropic_version"])) assert.NotContains(t, payload, "cache_control") assert.Contains(t, string(payload["messages"]), "cache_control") }) } } } func TestMantleRejectsInvalidRequestsAndPreservesExplicitModelIDs(t *testing.T) { for _, test := range []struct { name, model, body, wantModel string invalid bool }{ {name: "legacy Haiku alias", model: "claude-haiku-4-5-20251001", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"}, {name: "short Haiku alias", model: "claude-haiku-4-5", body: `{"messages":[]}`, wantModel: "anthropic.claude-haiku-4-5"}, {name: "explicit Haiku model", model: "anthropic.claude-haiku-4-5", body: `{"messages":[]}`}, {name: "explicit Haiku inference profile", model: "us.anthropic.claude-haiku-4-5", body: `{"messages":[]}`}, {name: "explicit regional model", model: "us.anthropic.claude-opus-4-7", body: `{"messages":[]}`}, {name: "explicit model", model: "anthropic.claude-opus-4-7", body: `{"messages":[]}`}, {name: "Nova requires legacy", model: "nova-pro-v1:0", body: `{"messages":[]}`, invalid: true}, {name: "null body", model: "claude-opus-4-7", body: `null`, invalid: true}, {name: "malformed JSON", model: "claude-opus-4-7", body: `{`, invalid: true}, } { t.Run(test.name, func(t *testing.T) { info := newMantleTestInfo() info.UpstreamModelName = test.model c := newAwsTestContext(httptest.NewRecorder(), context.Background()) req, err := (&Adaptor{}).newMantleRequest(c, info, strings.NewReader(test.body)) if test.invalid { require.Error(t, err) assert.Nil(t, req) return } require.NoError(t, err) defer req.Body.Close() var payload map[string]any require.NoError(t, common.DecodeJson(req.Body, &payload)) wantModel := test.wantModel if wantModel == "" { wantModel = test.model } assert.Equal(t, wantModel, payload["model"]) assert.NotContains(t, payload, "cache_control") }) } } func TestMantleSignatureMatchesWireHeaders(t *testing.T) { for _, test := range []struct { name string http2 bool allHopHeaders bool }{ {name: "HTTP2 keep-alive", http2: true}, {name: "HTTP1 hop-by-hop headers", allHopHeaders: true}, {name: "HTTP2 hop-by-hop headers", http2: true, allHopHeaders: true}, } { t.Run(test.name, func(t *testing.T) { type receivedRequest struct { request *http.Request body []byte err error } received := make(chan receivedRequest, 1) server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) received <- receivedRequest{request: r.Clone(context.Background()), body: body, err: err} w.WriteHeader(http.StatusOK) })) server.EnableHTTP2 = test.http2 server.StartTLS() defer server.Close() originalClient := http.DefaultClient http.DefaultClient = server.Client() defer func() { http.DefaultClient = originalClient }() info := newMantleTestInfo() info.ChannelBaseUrl = server.URL info.ApiKey = "access-key|secret-key|us-east-1" info.UseRuntimeHeadersOverride = true info.RuntimeHeadersOverride = map[string]any{ "Connection": "keep-alive", "anthropic-beta": "test-beta", "X-Test": "end-to-end", } if test.allHopHeaders { maps.Copy(info.RuntimeHeadersOverride, map[string]any{ "Connection": "keep-alive, x-hop-token", "X-Hop-Token": "hop-only", "Keep-Alive": "timeout=5", "Proxy-Connection": "keep-alive", "Proxy-Authenticate": "Basic", "Proxy-Authorization": "Basic test-proxy-token", "Te": "trailers", "Trailer": "X-Checksum", "Transfer-Encoding": "chunked", "Upgrade": "websocket", }) } c := newAwsTestContext(httptest.NewRecorder(), context.Background()) response, err := (&Adaptor{}).DoRequest(c, info, strings.NewReader(`{"messages":[],"max_tokens":128,"cache_control":{"type":"ephemeral","ttl":"1h"}}`)) require.NoError(t, err) httpResponse, ok := response.(*http.Response) require.True(t, ok) defer httpResponse.Body.Close() wire := <-received require.NoError(t, wire.err) wantProtocol := 1 if test.http2 { wantProtocol = 2 } require.Equal(t, wantProtocol, wire.request.ProtoMajor) for _, name := range []string{"Connection", "X-Hop-Token", "Keep-Alive", "Proxy-Connection", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade"} { assert.Empty(t, wire.request.Header.Values(name), "%s must not reach the upstream", name) } assert.Equal(t, "test-beta", wire.request.Header.Get("anthropic-beta")) assert.Equal(t, "end-to-end", wire.request.Header.Get("X-Test")) // Recompute the signature from only the bytes and signed headers that // reached the server, not from the pre-transport request object. authorization := wire.request.Header.Get("Authorization") _, signedHeaders, found := strings.Cut(authorization, "SignedHeaders=") require.True(t, found) signedHeaders, _, found = strings.Cut(signedHeaders, ",") require.True(t, found) verification := wire.request.Clone(context.Background()) verification.URL.Scheme = "https" verification.URL.Host = wire.request.Host verification.Header = make(http.Header) for name := range strings.SplitSeq(signedHeaders, ";") { if values, ok := wire.request.Header[http.CanonicalHeaderKey(name)]; ok { verification.Header[http.CanonicalHeaderKey(name)] = values } } signedAt, err := time.Parse("20060102T150405Z", wire.request.Header.Get("X-Amz-Date")) require.NoError(t, err) hash := sha256.Sum256(wire.body) err = v4.NewSigner().SignHTTP(context.Background(), aws.Credentials{AccessKeyID: "access-key", SecretAccessKey: "secret-key"}, verification, hex.EncodeToString(hash[:]), "bedrock-mantle", "us-east-1", signedAt) require.NoError(t, err) assert.Equal(t, authorization, verification.Header.Get("Authorization")) }) } } func TestAWSLegacyHaikuMappingPreservesVersionAndRegion(t *testing.T) { for _, test := range []struct { name string mode dto.AwsApiType stream bool key string }{ {name: "default API key", key: "test-api-key|us-east-1"}, {name: "default API key stream", stream: true, key: "test-api-key|us-east-1"}, {name: "default AK/SK", key: "access-key|secret-key|us-east-1"}, {name: "default AK/SK stream", stream: true, key: "access-key|secret-key|us-east-1"}, {name: "explicit API key", mode: dto.AwsApiTypeInvokeModel, key: "test-api-key|us-east-1"}, {name: "explicit API key stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "test-api-key|us-east-1"}, {name: "explicit AK/SK", mode: dto.AwsApiTypeInvokeModel, key: "access-key|secret-key|us-east-1"}, {name: "explicit AK/SK stream", mode: dto.AwsApiTypeInvokeModel, stream: true, key: "access-key|secret-key|us-east-1"}, } { t.Run(test.name, func(t *testing.T) { info := newMantleTestInfo() info.ChannelOtherSettings.AwsApiType = test.mode info.ApiKey, info.IsStream = test.key, test.stream info.UpstreamModelName = "claude-haiku-4-5-20251001" c := newAwsTestContext(httptest.NewRecorder(), context.Background()) a := &Adaptor{} a.Init(info) response, err := a.DoRequest(c, info, strings.NewReader(`{"messages":[{"role":"user","content":"hello"}],"max_tokens":128}`)) require.NoError(t, err) assert.Nil(t, response) const wantModel = "us.anthropic.claude-haiku-4-5-20251001-v1:0" if test.stream { request, ok := a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput) require.True(t, ok) assert.Equal(t, wantModel, *request.ModelId) } else { request, ok := a.AwsReq.(*bedrockruntime.InvokeModelInput) require.True(t, ok) assert.Equal(t, wantModel, *request.ModelId) } }) } } func TestMantleCanceledRequestSkipsRetry(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() c := newAwsTestContext(httptest.NewRecorder(), ctx) response, err := (&Adaptor{}).DoRequest(c, newMantleTestInfo(), strings.NewReader(`{"messages":[]}`)) require.Error(t, err) assert.Nil(t, response) var apiError *relaytypes.NewAPIError require.ErrorAs(t, err, &apiError) assert.True(t, relaytypes.IsSkipRetryError(apiError)) }