github.com/anthropics/anthropic-sdk-go@v1.70.1/bedrock/bedrock_test.go (about)

     1  package bedrock
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"os"
    13  	"path/filepath"
    14  	"strings"
    15  	"testing"
    16  
    17  	"github.com/aws/aws-sdk-go-v2/aws"
    18  	"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
    19  	"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
    20  	v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
    21  	"github.com/aws/aws-sdk-go-v2/credentials"
    22  	"github.com/aws/smithy-go/auth/bearer"
    23  
    24  	"github.com/anthropics/anthropic-sdk-go"
    25  	"github.com/anthropics/anthropic-sdk-go/option"
    26  )
    27  
    28  func TestBedrockURLEncoding(t *testing.T) {
    29  	testCases := []struct {
    30  		name            string
    31  		model           string
    32  		stream          bool
    33  		expectedPath    string
    34  		expectedRawPath string
    35  	}{
    36  		{
    37  			name:            "regular model name",
    38  			model:           "claude-3-sonnet",
    39  			stream:          false,
    40  			expectedPath:    "/model/claude-3-sonnet/invoke",
    41  			expectedRawPath: "/model/claude-3-sonnet/invoke",
    42  		},
    43  		{
    44  			name:            "regular model name with streaming",
    45  			model:           "claude-3-sonnet",
    46  			stream:          true,
    47  			expectedPath:    "/model/claude-3-sonnet/invoke-with-response-stream",
    48  			expectedRawPath: "/model/claude-3-sonnet/invoke-with-response-stream",
    49  		},
    50  		{
    51  			name:            "inference profile ARN with slashes",
    52  			model:           "arn:aws:bedrock:us-east-1:947123456126:application-inference-profile/xv9example4b",
    53  			stream:          false,
    54  			expectedPath:    "/model/arn:aws:bedrock:us-east-1:947123456126:application-inference-profile/xv9example4b/invoke",
    55  			expectedRawPath: "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A947123456126%3Aapplication-inference-profile%2Fxv9example4b/invoke",
    56  		},
    57  		{
    58  			name:            "inference profile ARN with streaming",
    59  			model:           "arn:aws:bedrock:us-east-1:947123456126:application-inference-profile/xv9example4b",
    60  			stream:          true,
    61  			expectedPath:    "/model/arn:aws:bedrock:us-east-1:947123456126:application-inference-profile/xv9example4b/invoke-with-response-stream",
    62  			expectedRawPath: "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A947123456126%3Aapplication-inference-profile%2Fxv9example4b/invoke-with-response-stream",
    63  		},
    64  		{
    65  			name:            "foundation model ARN with colons",
    66  			model:           "arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0",
    67  			stream:          false,
    68  			expectedPath:    "/model/arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0/invoke",
    69  			expectedRawPath: "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Afoundation-model%2Fanthropic.claude-3-sonnet-20240229-v1%3A0/invoke",
    70  		},
    71  	}
    72  
    73  	for _, tc := range testCases {
    74  		t.Run(tc.name, func(t *testing.T) {
    75  			// Create a mock AWS config
    76  			cfg := aws.Config{
    77  				Region: "us-east-1",
    78  				Credentials: credentials.StaticCredentialsProvider{
    79  					Value: aws.Credentials{
    80  						AccessKeyID:     "test-access-key",
    81  						SecretAccessKey: "test-secret-key",
    82  					},
    83  				},
    84  			}
    85  
    86  			signer := v4.NewSigner()
    87  			middleware := bedrockMiddleware(signer, cfg)
    88  
    89  			// Create request body
    90  			requestBody := map[string]any{
    91  				"model":  tc.model,
    92  				"stream": tc.stream,
    93  				"messages": []map[string]string{
    94  					{"role": "user", "content": "Hello"},
    95  				},
    96  			}
    97  
    98  			bodyBytes, err := json.Marshal(requestBody)
    99  			if err != nil {
   100  				t.Fatalf("Failed to marshal request body: %v", err)
   101  			}
   102  
   103  			// Create HTTP request
   104  			req, err := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/v1/messages", bytes.NewReader(bodyBytes))
   105  			if err != nil {
   106  				t.Fatalf("Failed to create request: %v", err)
   107  			}
   108  			req.Header.Set("Content-Type", "application/json")
   109  
   110  			// Apply middleware
   111  			_, err = middleware(req, func(r *http.Request) (*http.Response, error) {
   112  				// Verify the URL paths are set correctly
   113  				if r.URL.Path != tc.expectedPath {
   114  					t.Errorf("Expected Path %q, got %q", tc.expectedPath, r.URL.Path)
   115  				}
   116  
   117  				if r.URL.RawPath != tc.expectedRawPath {
   118  					t.Errorf("Expected RawPath %q, got %q", tc.expectedRawPath, r.URL.RawPath)
   119  				}
   120  
   121  				// Verify that the URL string contains the properly encoded path
   122  				urlString := r.URL.String()
   123  				expectedURL := fmt.Sprintf("https://bedrock-runtime.us-east-1.amazonaws.com%s", tc.expectedRawPath)
   124  				if urlString != expectedURL {
   125  					t.Errorf("Expected URL %q, got %q", expectedURL, urlString)
   126  				}
   127  
   128  				// Return a dummy response
   129  				return &http.Response{
   130  					StatusCode: 200,
   131  					Body:       http.NoBody,
   132  				}, nil
   133  			})
   134  
   135  			if err != nil {
   136  				t.Fatalf("Middleware failed: %v", err)
   137  			}
   138  		})
   139  	}
   140  }
   141  
   142  func TestBedrockBetaHeadersReRoutedThroughBody(t *testing.T) {
   143  	// Create a mock AWS config
   144  	cfg := aws.Config{
   145  		Region: "us-east-1",
   146  		Credentials: credentials.StaticCredentialsProvider{
   147  			Value: aws.Credentials{
   148  				AccessKeyID:     "test-access-key",
   149  				SecretAccessKey: "test-secret-key",
   150  			},
   151  		},
   152  	}
   153  
   154  	signer := v4.NewSigner()
   155  	middleware := bedrockMiddleware(signer, cfg)
   156  
   157  	// Create HTTP request with beta headers
   158  	type fakeRequest struct {
   159  		Model         string              `json:"model"`
   160  		AnthropicBeta []string            `json:"anthropic_beta,omitempty"`
   161  		Messages      []map[string]string `json:"messages"`
   162  	}
   163  	reqBody := fakeRequest{
   164  		Model: "fake-model",
   165  		Messages: []map[string]string{
   166  			{"role": "user", "content": "Hello"},
   167  		},
   168  	}
   169  	requestBodyBytes, err := json.Marshal(reqBody)
   170  	if err != nil {
   171  		t.Fatalf("Failed to marshal request body: %v", err)
   172  	}
   173  
   174  	req, err := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/v1/messages", bytes.NewReader(requestBodyBytes))
   175  	if err != nil {
   176  		t.Fatalf("Failed to create request: %v", err)
   177  	}
   178  	req.Header.Set("Content-Type", "application/json")
   179  	req.Header.Add("anthropic-beta", "beta-feature-1")
   180  	req.Header.Add("anthropic-beta", "beta-feature-2")
   181  
   182  	// Apply middleware
   183  	_, err = middleware(req, func(r *http.Request) (*http.Response, error) {
   184  		// Read the modified body
   185  		bodyBytes, err := io.ReadAll(r.Body)
   186  		if err != nil {
   187  			t.Fatalf("Failed to read request body: %v", err)
   188  		}
   189  		var modifiedBody fakeRequest
   190  		err = json.Unmarshal(bodyBytes, &modifiedBody)
   191  		if err != nil {
   192  			t.Fatalf("Failed to unmarshal modified body: %v", err)
   193  		}
   194  
   195  		// Verify that the anthropic_beta field is present in the body
   196  		expectedBetas := []string{"beta-feature-1", "beta-feature-2"}
   197  		if len(modifiedBody.AnthropicBeta) != len(expectedBetas) {
   198  			t.Fatalf("Expected %d beta features, got %d", len(expectedBetas), len(modifiedBody.AnthropicBeta))
   199  		}
   200  		for i, beta := range expectedBetas {
   201  			if modifiedBody.AnthropicBeta[i] != beta {
   202  				t.Errorf("Expected beta feature %q, got %q", beta, modifiedBody.AnthropicBeta[i])
   203  			}
   204  		}
   205  
   206  		// Return a dummy response
   207  		return &http.Response{
   208  			StatusCode: 200,
   209  			Body:       http.NoBody,
   210  		}, nil
   211  	})
   212  
   213  	if err != nil {
   214  		t.Fatalf("Middleware failed: %v", err)
   215  	}
   216  }
   217  
   218  func TestBedrockBearerToken(t *testing.T) {
   219  	token := "test-bearer-token"
   220  	region := "us-west-2"
   221  
   222  	cfg := aws.Config{
   223  		Region:                  region,
   224  		BearerAuthTokenProvider: NewStaticBearerTokenProvider(token),
   225  	}
   226  	middleware := bedrockMiddleware(nil, cfg)
   227  
   228  	requestBody := map[string]any{
   229  		"model": "claude-3-sonnet",
   230  		"messages": []map[string]string{
   231  			{"role": "user", "content": "Hello"},
   232  		},
   233  	}
   234  
   235  	bodyBytes, err := json.Marshal(requestBody)
   236  	if err != nil {
   237  		t.Fatalf("Failed to marshal request body: %v", err)
   238  	}
   239  
   240  	req, err := http.NewRequest("POST", "https://bedrock-runtime.us-west-2.amazonaws.com/v1/messages", bytes.NewReader(bodyBytes))
   241  	if err != nil {
   242  		t.Fatalf("Failed to create request: %v", err)
   243  	}
   244  	req.Header.Set("Content-Type", "application/json")
   245  
   246  	_, err = middleware(req, func(r *http.Request) (*http.Response, error) {
   247  		authHeader := r.Header.Get("Authorization")
   248  		expectedAuth := "Bearer " + token
   249  		if authHeader != expectedAuth {
   250  			t.Errorf("Expected Authorization header %q, got %q", expectedAuth, authHeader)
   251  		}
   252  
   253  		if r.Header.Get("X-Amz-Date") != "" {
   254  			t.Error("Expected no AWS SigV4 headers when using bearer token")
   255  		}
   256  
   257  		return &http.Response{
   258  			StatusCode: 200,
   259  			Body:       http.NoBody,
   260  		}, nil
   261  	})
   262  
   263  	if err != nil {
   264  		t.Fatalf("Middleware failed: %v", err)
   265  	}
   266  }
   267  
   268  type roundTripperFunc func(*http.Request) (*http.Response, error)
   269  
   270  func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
   271  
   272  func TestBedrockAuthModeResolution(t *testing.T) {
   273  	const providerToken = "provider-token"
   274  	const envToken = "env-token"
   275  
   276  	tests := []struct {
   277  		name         string
   278  		envToken     string
   279  		preference   []string
   280  		credentials  bool
   281  		provider     bool
   282  		wantBearer   string
   283  		wantProvider bool
   284  	}{
   285  		{
   286  			name:        "an SSO token provider beside static credentials signs with SigV4",
   287  			credentials: true,
   288  			provider:    true,
   289  		},
   290  		{
   291  			name:        "the environment token wins over credentials and a provider",
   292  			envToken:    envToken,
   293  			credentials: true,
   294  			provider:    true,
   295  			wantBearer:  envToken,
   296  		},
   297  		{
   298  			name:         "an auth scheme preference led by httpBearerAuth uses the provider",
   299  			preference:   []string{"httpBearerAuth", "sigv4"},
   300  			credentials:  true,
   301  			provider:     true,
   302  			wantBearer:   providerToken,
   303  			wantProvider: true,
   304  		},
   305  		{
   306  			name:         "a provider without credentials uses the provider",
   307  			provider:     true,
   308  			wantBearer:   providerToken,
   309  			wantProvider: true,
   310  		},
   311  	}
   312  
   313  	for _, tt := range tests {
   314  		t.Run(tt.name, func(t *testing.T) {
   315  			t.Setenv("ANTHROPIC_API_KEY", "")
   316  			t.Setenv("AWS_BEARER_TOKEN_BEDROCK", tt.envToken)
   317  
   318  			providerCalled := false
   319  			cfg := aws.Config{Region: "us-east-1", AuthSchemePreference: tt.preference}
   320  			if tt.credentials {
   321  				cfg.Credentials = makeStaticAWSConfig("us-east-1").Credentials
   322  			}
   323  			if tt.provider {
   324  				cfg.BearerAuthTokenProvider = bearer.TokenProviderFunc(func(context.Context) (bearer.Token, error) {
   325  					providerCalled = true
   326  					return bearer.Token{Value: providerToken}, nil
   327  				})
   328  			}
   329  
   330  			var gotAuth string
   331  			transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) {
   332  				gotAuth = r.Header.Get("Authorization")
   333  				return &http.Response{
   334  					StatusCode: http.StatusOK,
   335  					Header:     http.Header{"Content-Type": []string{"application/json"}},
   336  					Body:       io.NopCloser(strings.NewReader(`{}`)),
   337  					Request:    r,
   338  				}, nil
   339  			})
   340  
   341  			client := anthropic.NewClient(
   342  				WithConfig(cfg),
   343  				option.WithHTTPClient(&http.Client{Transport: transport}),
   344  			)
   345  			_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   346  				Model:     "claude-3-sonnet",
   347  				MaxTokens: 1,
   348  				Messages: []anthropic.MessageParam{
   349  					anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   350  				},
   351  			})
   352  			if err != nil {
   353  				t.Fatalf("request failed: %v", err)
   354  			}
   355  
   356  			if tt.wantBearer != "" {
   357  				if gotAuth != "Bearer "+tt.wantBearer {
   358  					t.Errorf("expected a bearer Authorization header with the expected token, got scheme %q", strings.SplitN(gotAuth, " ", 2)[0])
   359  				}
   360  			} else if !strings.HasPrefix(gotAuth, "AWS4-HMAC-SHA256 ") {
   361  				t.Errorf("expected a SigV4 Authorization header, got scheme %q", strings.SplitN(gotAuth, " ", 2)[0])
   362  			}
   363  			if providerCalled != tt.wantProvider {
   364  				t.Errorf("bearer provider called = %v, want %v", providerCalled, tt.wantProvider)
   365  			}
   366  		})
   367  	}
   368  }
   369  
   370  // TestBedrockWithConfigRequiresCredentials verifies that a config with no
   371  // bearer token and no AWS credentials fails with a clear setup error rather
   372  // than a nil-pointer panic on the first request.
   373  func TestBedrockWithConfigRequiresCredentials(t *testing.T) {
   374  	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
   375  
   376  	client := anthropic.NewClient(
   377  		option.WithoutEnvironmentDefaults(),
   378  		WithConfig(aws.Config{Region: "us-east-1"}),
   379  	)
   380  
   381  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   382  		Model:     "claude-3-sonnet",
   383  		MaxTokens: 1,
   384  		Messages: []anthropic.MessageParam{
   385  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   386  		},
   387  	})
   388  
   389  	if err == nil || !strings.Contains(err.Error(), "expected AWS credentials to be set") {
   390  		t.Fatalf("Expected credentials error, got: %v", err)
   391  	}
   392  }
   393  
   394  // --- EventStream → SSE response normalization tests ---
   395  
   396  // encodeChunkFrame writes an EventStream "chunk" event frame whose payload
   397  // carries the given Anthropic event JSON, the way Bedrock streams responses.
   398  func encodeChunkFrame(t *testing.T, w io.Writer, eventJSON string) {
   399  	t.Helper()
   400  	payload, err := json.Marshal(eventstreamChunk{Bytes: base64.StdEncoding.EncodeToString([]byte(eventJSON))})
   401  	if err != nil {
   402  		t.Fatalf("Failed to marshal chunk payload: %v", err)
   403  	}
   404  	msg := eventstream.Message{Payload: payload}
   405  	msg.Headers.Set(eventstreamapi.MessageTypeHeader, eventstream.StringValue(eventstreamapi.EventMessageType))
   406  	msg.Headers.Set(eventstreamapi.EventTypeHeader, eventstream.StringValue("chunk"))
   407  	if err := eventstream.NewEncoder().Encode(w, msg); err != nil {
   408  		t.Fatalf("Failed to encode event frame: %v", err)
   409  	}
   410  }
   411  
   412  // encodeExceptionFrame writes an EventStream exception frame, the way Bedrock
   413  // reports mid-stream errors such as throttling.
   414  func encodeExceptionFrame(t *testing.T, w io.Writer, exceptionType, message string) {
   415  	t.Helper()
   416  	payload, err := json.Marshal(map[string]string{"message": message})
   417  	if err != nil {
   418  		t.Fatalf("Failed to marshal exception payload: %v", err)
   419  	}
   420  	msg := eventstream.Message{Payload: payload}
   421  	msg.Headers.Set(eventstreamapi.MessageTypeHeader, eventstream.StringValue(eventstreamapi.ExceptionMessageType))
   422  	msg.Headers.Set(eventstreamapi.ExceptionTypeHeader, eventstream.StringValue(exceptionType))
   423  	if err := eventstream.NewEncoder().Encode(w, msg); err != nil {
   424  		t.Fatalf("Failed to encode exception frame: %v", err)
   425  	}
   426  }
   427  
   428  // applyStreamingMiddleware runs bedrockMiddleware over a fake streaming
   429  // request, with the wire responding with the given EventStream body, and
   430  // returns the response the middleware produced.
   431  func applyStreamingMiddleware(t *testing.T, frames *bytes.Buffer) *http.Response {
   432  	t.Helper()
   433  	middleware := bedrockMiddleware(v4.NewSigner(), makeStaticAWSConfig("us-east-1"))
   434  
   435  	body := `{"model": "claude-3-sonnet", "stream": true, "messages": [{"role": "user", "content": "Hello"}]}`
   436  	req, err := http.NewRequest("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/v1/messages", strings.NewReader(body))
   437  	if err != nil {
   438  		t.Fatalf("Failed to create request: %v", err)
   439  	}
   440  
   441  	res, err := middleware(req, func(r *http.Request) (*http.Response, error) {
   442  		return &http.Response{
   443  			StatusCode: 200,
   444  			Header:     http.Header{"Content-Type": []string{"application/vnd.amazon.eventstream"}},
   445  			Body:       io.NopCloser(frames),
   446  		}, nil
   447  	})
   448  	if err != nil {
   449  		t.Fatalf("Middleware failed: %v", err)
   450  	}
   451  	return res
   452  }
   453  
   454  func TestBedrockStreamingResponseNormalizedToSSE(t *testing.T) {
   455  	messageStartJSON := `{"type":"message_start","message":{"id":"msg_test"}}`
   456  	deltaJSON := `{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}`
   457  	frames := &bytes.Buffer{}
   458  	encodeChunkFrame(t, frames, messageStartJSON)
   459  	encodeChunkFrame(t, frames, deltaJSON)
   460  
   461  	res := applyStreamingMiddleware(t, frames)
   462  
   463  	if got := res.Header.Get("Content-Type"); got != "text/event-stream" {
   464  		t.Errorf("Expected Content-Type %q, got %q", "text/event-stream", got)
   465  	}
   466  	sse, err := io.ReadAll(res.Body)
   467  	if err != nil {
   468  		t.Fatalf("Failed to read normalized body: %v", err)
   469  	}
   470  	expected := "event: message_start\ndata: " + messageStartJSON + "\n\n" +
   471  		"event: content_block_delta\ndata: " + deltaJSON + "\n\n"
   472  	if string(sse) != expected {
   473  		t.Errorf("Expected SSE body %q, got %q", expected, string(sse))
   474  	}
   475  }
   476  
   477  func TestBedrockStreamingExceptionSurfacesAsBodyError(t *testing.T) {
   478  	messageStartJSON := `{"type":"message_start","message":{"id":"msg_test"}}`
   479  	frames := &bytes.Buffer{}
   480  	encodeChunkFrame(t, frames, messageStartJSON)
   481  	encodeExceptionFrame(t, frames, "ThrottlingException", "Too many requests")
   482  
   483  	res := applyStreamingMiddleware(t, frames)
   484  
   485  	sse, err := io.ReadAll(res.Body)
   486  	if err == nil {
   487  		t.Fatal("Expected an error reading a stream containing an exception frame")
   488  	}
   489  	expectedErr := "received exception ThrottlingException: Too many requests"
   490  	if err.Error() != expectedErr {
   491  		t.Errorf("Expected error %q, got %q", expectedErr, err.Error())
   492  	}
   493  	// Events decoded before the exception must still be delivered.
   494  	expectedSSE := "event: message_start\ndata: " + messageStartJSON + "\n\n"
   495  	if string(sse) != expectedSSE {
   496  		t.Errorf("Expected SSE body %q before the error, got %q", expectedSSE, string(sse))
   497  	}
   498  }
   499  
   500  // --- Middleware ordering tests ---
   501  
   502  // TestBedrockUserMiddlewareObservesAnthropicShape verifies the documented
   503  // ordering: middleware registered before the Bedrock option observes the
   504  // Anthropic-shaped, unsigned request, while the wire receives the rewritten,
   505  // signed Bedrock request.
   506  func TestBedrockUserMiddlewareObservesAnthropicShape(t *testing.T) {
   507  	t.Setenv("ANTHROPIC_API_KEY", "")
   508  	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
   509  
   510  	var wirePath, wireAuth string
   511  	var wireBody map[string]any
   512  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   513  		wirePath = r.URL.Path
   514  		wireAuth = r.Header.Get("Authorization")
   515  		if err := json.NewDecoder(r.Body).Decode(&wireBody); err != nil {
   516  			t.Errorf("Failed to decode wire body: %v", err)
   517  		}
   518  		writeMessagesResponse(w)
   519  	}))
   520  	t.Cleanup(server.Close)
   521  
   522  	var observedPath, observedAuth string
   523  	var observedBody map[string]any
   524  	spy := func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) {
   525  		observedPath = r.URL.Path
   526  		observedAuth = r.Header.Get("Authorization")
   527  		body, err := io.ReadAll(r.Body)
   528  		if err != nil {
   529  			return nil, err
   530  		}
   531  		if err := json.Unmarshal(body, &observedBody); err != nil {
   532  			return nil, err
   533  		}
   534  		r.Body = io.NopCloser(bytes.NewReader(body))
   535  		return next(r)
   536  	}
   537  
   538  	client := anthropic.NewClient(
   539  		option.WithoutEnvironmentDefaults(),
   540  		option.WithMiddleware(spy),
   541  		WithConfig(makeStaticAWSConfig("us-east-1")),
   542  		option.WithBaseURL(server.URL),
   543  	)
   544  
   545  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   546  		Model:     "claude-3-sonnet",
   547  		MaxTokens: 1,
   548  		Messages: []anthropic.MessageParam{
   549  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   550  		},
   551  	})
   552  	if err != nil {
   553  		t.Fatalf("Request failed: %v", err)
   554  	}
   555  
   556  	// The spy (outside the Bedrock adaptation) sees the Anthropic shape.
   557  	if observedPath != "/v1/messages" {
   558  		t.Errorf("Expected middleware to observe path %q, got %q", "/v1/messages", observedPath)
   559  	}
   560  	if observedBody["model"] != "claude-3-sonnet" {
   561  		t.Errorf("Expected middleware to observe model in body, got %v", observedBody["model"])
   562  	}
   563  	if observedAuth != "" {
   564  		t.Errorf("Expected middleware to observe no Authorization header, got %q", observedAuth)
   565  	}
   566  
   567  	// The wire sees the rewritten, signed Bedrock shape.
   568  	if wirePath != "/model/claude-3-sonnet/invoke" {
   569  		t.Errorf("Expected wire path %q, got %q", "/model/claude-3-sonnet/invoke", wirePath)
   570  	}
   571  	if _, ok := wireBody["model"]; ok {
   572  		t.Error("Expected model to be removed from the wire body")
   573  	}
   574  	if wireBody["anthropic_version"] != DefaultVersion {
   575  		t.Errorf("Expected anthropic_version %q on the wire, got %v", DefaultVersion, wireBody["anthropic_version"])
   576  	}
   577  	if !strings.HasPrefix(wireAuth, "AWS4-HMAC-SHA256") {
   578  		t.Errorf("Expected SigV4 Authorization on the wire, got %q", wireAuth)
   579  	}
   580  }
   581  
   582  // TestBedrockStreamingEndToEnd verifies that an EventStream wire response
   583  // decodes into the same stream events a first-party SSE response would.
   584  func TestBedrockStreamingEndToEnd(t *testing.T) {
   585  	t.Setenv("ANTHROPIC_API_KEY", "")
   586  	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
   587  
   588  	eventJSONs := []string{
   589  		`{"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-3-sonnet","usage":{"input_tokens":1,"output_tokens":1}}}`,
   590  		`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
   591  		`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`,
   592  		`{"type":"content_block_stop","index":0}`,
   593  		`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
   594  		`{"type":"message_stop"}`,
   595  	}
   596  	frames := &bytes.Buffer{}
   597  	for _, eventJSON := range eventJSONs {
   598  		encodeChunkFrame(t, frames, eventJSON)
   599  	}
   600  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   601  		w.Header().Set("Content-Type", "application/vnd.amazon.eventstream")
   602  		w.Write(frames.Bytes())
   603  	}))
   604  	t.Cleanup(server.Close)
   605  
   606  	client := anthropic.NewClient(
   607  		option.WithoutEnvironmentDefaults(),
   608  		WithConfig(makeStaticAWSConfig("us-east-1")),
   609  		option.WithBaseURL(server.URL),
   610  	)
   611  
   612  	stream := client.Messages.NewStreaming(context.Background(), anthropic.MessageNewParams{
   613  		Model:     "claude-3-sonnet",
   614  		MaxTokens: 1,
   615  		Messages: []anthropic.MessageParam{
   616  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   617  		},
   618  	})
   619  
   620  	var gotTypes []string
   621  	for stream.Next() {
   622  		gotTypes = append(gotTypes, string(stream.Current().Type))
   623  	}
   624  	if err := stream.Err(); err != nil {
   625  		t.Fatalf("Expected no stream error, got: %v", err)
   626  	}
   627  
   628  	expectedTypes := []string{
   629  		"message_start", "content_block_start", "content_block_delta",
   630  		"content_block_stop", "message_delta", "message_stop",
   631  	}
   632  	if len(gotTypes) != len(expectedTypes) {
   633  		t.Fatalf("Expected %d events %v, got %d: %v", len(expectedTypes), expectedTypes, len(gotTypes), gotTypes)
   634  	}
   635  	for i, expected := range expectedTypes {
   636  		if gotTypes[i] != expected {
   637  			t.Errorf("Expected event %d to be %q, got %q", i, expected, gotTypes[i])
   638  		}
   639  	}
   640  }
   641  
   642  // TestBedrockClientIgnoresConfigStore is a regression test for the
   643  // first-party credential chain leaking into Bedrock clients: a client
   644  // constructed with static AWS credentials must never consult the shared
   645  // config store (ANTHROPIC_CONFIG_DIR), so no store-derived value — the
   646  // resolvable profile's workspace id or its access token — may reach the
   647  // wire. Authorization must stay a SigV4 signature from the static AWS
   648  // credentials.
   649  func TestBedrockClientIgnoresConfigStore(t *testing.T) {
   650  	t.Setenv("ANTHROPIC_API_KEY", "")
   651  	t.Setenv("ANTHROPIC_AUTH_TOKEN", "")
   652  	unsetEnv(t, "ANTHROPIC_PROFILE")
   653  	unsetEnv(t, "ANTHROPIC_FEDERATION_RULE_ID")
   654  	unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID")
   655  	unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN")
   656  	unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE")
   657  	unsetEnv(t, "ANTHROPIC_WORKSPACE_ID")
   658  	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
   659  	writeFakeConfigStore(t)
   660  
   661  	var wireHeaders http.Header
   662  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   663  		wireHeaders = r.Header.Clone()
   664  		writeMessagesResponse(w)
   665  	}))
   666  	t.Cleanup(server.Close)
   667  
   668  	client := anthropic.NewClient(
   669  		WithConfig(makeStaticAWSConfig("us-east-1")),
   670  		option.WithBaseURL(server.URL),
   671  	)
   672  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   673  		Model:     "claude-3-sonnet",
   674  		MaxTokens: 1,
   675  		Messages: []anthropic.MessageParam{
   676  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   677  		},
   678  	})
   679  	if err != nil {
   680  		t.Fatalf("Request failed: %v", err)
   681  	}
   682  
   683  	if got, ok := wireHeaders[http.CanonicalHeaderKey("anthropic-workspace-id")]; ok {
   684  		t.Errorf("Expected no anthropic-workspace-id header, got %q", got)
   685  	}
   686  	if got := wireHeaders.Get("X-Api-Key"); got != "" {
   687  		t.Errorf("Expected no X-Api-Key header, got %q", got)
   688  	}
   689  	if auth := wireHeaders.Get("Authorization"); !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-access-key/") {
   690  		t.Errorf("Expected SigV4 Authorization from the static AWS credentials, got %q", auth)
   691  	}
   692  }
   693  
   694  // writeFakeConfigStore points ANTHROPIC_CONFIG_DIR at a temp config store
   695  // holding a resolvable default profile with a workspace id, so a client
   696  // that (incorrectly) walks the first-party credential chain would pick it
   697  // up. Values are deliberately fake.
   698  func writeFakeConfigStore(t *testing.T) {
   699  	t.Helper()
   700  	dir := t.TempDir()
   701  	if err := os.MkdirAll(filepath.Join(dir, "configs"), 0o755); err != nil {
   702  		t.Fatal(err)
   703  	}
   704  	if err := os.MkdirAll(filepath.Join(dir, "credentials"), 0o700); err != nil {
   705  		t.Fatal(err)
   706  	}
   707  	if err := os.WriteFile(filepath.Join(dir, "configs", "default.json"), []byte(`{
   708  		"authentication": {"type": "user_oauth"},
   709  		"workspace_id": "wrkspc_fake_store_value"
   710  	}`), 0o644); err != nil {
   711  		t.Fatal(err)
   712  	}
   713  	if err := os.WriteFile(filepath.Join(dir, "credentials", "default.json"),
   714  		[]byte(`{"type":"oauth_token","access_token":"fake-store-access-token"}`), 0o600); err != nil {
   715  		t.Fatal(err)
   716  	}
   717  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   718  }
   719  
   720  // unsetEnv unsets an env var for the duration of the test, restoring the
   721  // original value afterwards (t.Setenv registers the restore).
   722  func unsetEnv(t *testing.T, key string) {
   723  	t.Helper()
   724  	t.Setenv(key, "")
   725  	os.Unsetenv(key)
   726  }
   727  
   728  // TestBedrockNonJSONBodyPassesThroughUntouched verifies that a raw,
   729  // non-JSON request body reaches the wire byte-for-byte and is still
   730  // SigV4-signed, rather than being replaced by injected JSON fields.
   731  func TestBedrockNonJSONBodyPassesThroughUntouched(t *testing.T) {
   732  	t.Setenv("ANTHROPIC_API_KEY", "")
   733  	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "")
   734  
   735  	payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x01, 0x02, 0xff}
   736  
   737  	var wireBody []byte
   738  	var wireAuth, wireContentType string
   739  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   740  		wireAuth = r.Header.Get("Authorization")
   741  		wireContentType = r.Header.Get("Content-Type")
   742  		var err error
   743  		if wireBody, err = io.ReadAll(r.Body); err != nil {
   744  			t.Errorf("Failed to read wire body: %v", err)
   745  		}
   746  		w.Header().Set("Content-Type", "application/json")
   747  		w.Write([]byte(`{}`))
   748  	}))
   749  	t.Cleanup(server.Close)
   750  
   751  	client := anthropic.NewClient(
   752  		option.WithoutEnvironmentDefaults(),
   753  		WithConfig(makeStaticAWSConfig("us-east-1")),
   754  		option.WithBaseURL(server.URL),
   755  	)
   756  
   757  	var res map[string]any
   758  	err := client.Post(context.Background(), "/model/anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", nil, &res,
   759  		option.WithRequestBody("application/octet-stream", bytes.NewReader(payload)),
   760  	)
   761  	if err != nil {
   762  		t.Fatalf("Request failed: %v", err)
   763  	}
   764  
   765  	if !bytes.Equal(wireBody, payload) {
   766  		t.Errorf("Expected wire body %x, got %x (%q)", payload, wireBody, wireBody)
   767  	}
   768  	if wireContentType != "application/octet-stream" {
   769  		t.Errorf("Expected Content-Type %q on the wire, got %q", "application/octet-stream", wireContentType)
   770  	}
   771  	if !strings.HasPrefix(wireAuth, "AWS4-HMAC-SHA256") {
   772  		t.Errorf("Expected SigV4 Authorization on the wire, got %q", wireAuth)
   773  	}
   774  }