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 }