github.com/anthropics/anthropic-sdk-go@v1.70.1/bedrock/bedrockmantle_test.go (about) 1 package bedrock 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "io" 8 "net/http" 9 "net/http/httptest" 10 "strings" 11 "testing" 12 13 awssdk "github.com/aws/aws-sdk-go-v2/aws" 14 v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" 15 "github.com/aws/aws-sdk-go-v2/credentials" 16 17 "github.com/anthropics/anthropic-sdk-go" 18 "github.com/anthropics/anthropic-sdk-go/internal/awsauth" 19 "github.com/anthropics/anthropic-sdk-go/option" 20 ) 21 22 func makeStaticAWSConfig(region string) awssdk.Config { 23 return awssdk.Config{ 24 Region: region, 25 Credentials: credentials.StaticCredentialsProvider{ 26 Value: awssdk.Credentials{ 27 AccessKeyID: "test-access-key", 28 SecretAccessKey: "test-secret-key", 29 }, 30 }, 31 } 32 } 33 34 // writeMessagesResponse writes a canned successful Messages API response. 35 func writeMessagesResponse(w http.ResponseWriter) { 36 w.Header().Set("Content-Type", "application/json") 37 json.NewEncoder(w).Encode(map[string]any{ 38 "id": "msg_test", 39 "type": "message", 40 "role": "assistant", 41 "content": []map[string]any{{"type": "text", "text": "hi"}}, 42 "model": "claude-sonnet-4-6-20250514", 43 "stop_reason": "end_turn", 44 "stop_sequence": nil, 45 "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, 46 }) 47 } 48 49 type mantleCapturedRequest struct { 50 Headers http.Header 51 URL string 52 } 53 54 func mantleMessagesHandler(captured *mantleCapturedRequest) http.HandlerFunc { 55 return func(w http.ResponseWriter, r *http.Request) { 56 captured.Headers = r.Header.Clone() 57 captured.URL = r.URL.String() 58 writeMessagesResponse(w) 59 } 60 } 61 62 // newTestMantleClient creates a MantleClient pointed at a test server and returns 63 // the client and a struct that captures the request headers/URL. 64 func newTestMantleClient(t *testing.T, cfg MantleClientConfig, opts ...option.RequestOption) (*MantleClient, *mantleCapturedRequest) { 65 t.Helper() 66 var captured mantleCapturedRequest 67 server := httptest.NewServer(mantleMessagesHandler(&captured)) 68 t.Cleanup(server.Close) 69 70 cfg.BaseURL = server.URL 71 client, err := NewMantleClient(context.Background(), cfg, opts...) 72 if err != nil { 73 t.Fatalf("NewMantleClient failed: %v", err) 74 } 75 return client, &captured 76 } 77 78 func sendTestMantleRequest(t *testing.T, client *MantleClient) { 79 t.Helper() 80 _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ 81 Model: "claude-sonnet-4-6-20250514", 82 MaxTokens: 1, 83 Messages: []anthropic.MessageParam{ 84 anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), 85 }, 86 }) 87 if err != nil { 88 t.Fatalf("request failed: %v", err) 89 } 90 } 91 92 // --- Validation tests --- 93 94 func TestMantleRequiresBaseURLOrRegion(t *testing.T) { 95 t.Setenv("AWS_REGION", "") 96 t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") 97 98 _, err := NewMantleClient(context.Background(), MantleClientConfig{ 99 APIKey: "my-key", 100 }) 101 if err == nil { 102 t.Fatal("expected error when neither base URL nor region is available") 103 } 104 } 105 106 func TestMantleRegionRequiredForSigV4(t *testing.T) { 107 t.Setenv("AWS_REGION", "") 108 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 109 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 110 111 _, err := NewMantleClient(context.Background(), MantleClientConfig{ 112 AWSAccessKey: "key", 113 AWSSecretAccessKey: "secret", 114 }) 115 if err == nil { 116 t.Fatal("expected error when region is missing and SigV4 is used") 117 } 118 } 119 120 // --- API key mode tests --- 121 122 func TestMantleAPIKeyModeHeaders(t *testing.T) { 123 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 124 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 125 126 client, captured := newTestMantleClient(t, MantleClientConfig{ 127 APIKey: "my-api-key", 128 AWSRegion: "us-east-1", 129 }) 130 sendTestMantleRequest(t, client) 131 132 if got := captured.Headers.Get("Authorization"); got != "Bearer my-api-key" { 133 t.Errorf("expected Authorization %q, got %q", "Bearer my-api-key", got) 134 } 135 if captured.Headers.Get("X-Api-Key") != "" { 136 t.Error("expected no X-Api-Key header in bearer auth mode") 137 } 138 } 139 140 // --- SigV4 mode tests --- 141 142 func TestMantleSigV4ModeHeaders(t *testing.T) { 143 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 144 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 145 t.Setenv("ANTHROPIC_API_KEY", "") 146 147 client, captured := newTestMantleClient(t, MantleClientConfig{ 148 AWSRegion: "us-east-1", 149 AWSAccessKey: "test-access-key", 150 AWSSecretAccessKey: "test-secret-key", 151 }) 152 sendTestMantleRequest(t, client) 153 154 auth := captured.Headers.Get("Authorization") 155 if auth == "" { 156 t.Fatal("expected Authorization header in SigV4 mode") 157 } 158 if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { 159 t.Errorf("expected AWS4 signature, got: %s", auth) 160 } 161 if !strings.Contains(auth, mantleServiceName) { 162 t.Errorf("expected service name %q in Authorization, got: %s", mantleServiceName, auth) 163 } 164 if captured.Headers.Get("X-Amz-Date") == "" { 165 t.Error("expected X-Amz-Date header in SigV4 mode") 166 } 167 } 168 169 // --- SigV4 middleware service name test --- 170 171 func TestMantleSigV4ServiceName(t *testing.T) { 172 cfg := makeStaticAWSConfig("us-east-1") 173 signer := v4.NewSigner() 174 middleware := awsauth.SigV4Middleware(signer, cfg, mantleServiceName) 175 176 req, err := http.NewRequest("POST", "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages", bytes.NewReader([]byte(`{}`))) 177 if err != nil { 178 t.Fatalf("failed to create request: %v", err) 179 } 180 181 var gotAuth string 182 _, err = middleware(req, func(r *http.Request) (*http.Response, error) { 183 gotAuth = r.Header.Get("Authorization") 184 return &http.Response{StatusCode: 200, Body: http.NoBody}, nil 185 }) 186 if err != nil { 187 t.Fatalf("middleware failed: %v", err) 188 } 189 190 if gotAuth == "" { 191 t.Fatal("expected non-empty Authorization header") 192 } 193 expectedServiceFragment := "/" + mantleServiceName + "/" 194 if !bytes.Contains([]byte(gotAuth), []byte(expectedServiceFragment)) { 195 t.Errorf("expected Authorization to contain service %q, got: %s", mantleServiceName, gotAuth) 196 } 197 } 198 199 // --- Env var fallback tests --- 200 201 func TestMantleAPIKeyFallbackToAWSEnv(t *testing.T) { 202 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 203 t.Setenv("ANTHROPIC_AWS_API_KEY", "aws-fallback-key") 204 t.Setenv("AWS_REGION", "us-east-1") 205 206 client, captured := newTestMantleClient(t, MantleClientConfig{}) 207 sendTestMantleRequest(t, client) 208 209 if got := captured.Headers.Get("Authorization"); got != "Bearer aws-fallback-key" { 210 t.Errorf("expected Authorization %q (AWS fallback), got %q", "Bearer aws-fallback-key", got) 211 } 212 } 213 214 func TestMantleAPIKeyMantleEnvOverridesAWSEnv(t *testing.T) { 215 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "mantle-key") 216 t.Setenv("ANTHROPIC_AWS_API_KEY", "aws-key") 217 t.Setenv("AWS_REGION", "us-east-1") 218 219 client, captured := newTestMantleClient(t, MantleClientConfig{}) 220 sendTestMantleRequest(t, client) 221 222 if got := captured.Headers.Get("Authorization"); got != "Bearer mantle-key" { 223 t.Errorf("expected Authorization %q (mantle-specific), got %q", "Bearer mantle-key", got) 224 } 225 } 226 227 // --- Base URL tests --- 228 229 func TestMantleBaseURLDerivedFromRegion(t *testing.T) { 230 t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") 231 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 232 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 233 234 resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{ 235 AWSRegion: "us-west-2", 236 APIKey: "my-key", 237 }), mantleResolveParams()) 238 if err != nil { 239 t.Fatalf("unexpected error: %v", err) 240 } 241 expected := "https://bedrock-mantle.us-west-2.api.aws/anthropic" 242 if resolved.BaseURL != expected { 243 t.Errorf("expected base URL %q, got %q", expected, resolved.BaseURL) 244 } 245 } 246 247 func TestMantleBaseURLFromEnv(t *testing.T) { 248 t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "https://custom.mantle.example.com") 249 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "my-key") 250 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 251 252 resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{}), mantleResolveParams()) 253 if err != nil { 254 t.Fatalf("unexpected error: %v", err) 255 } 256 if resolved.BaseURL != "https://custom.mantle.example.com" { 257 t.Errorf("expected base URL from env, got %q", resolved.BaseURL) 258 } 259 } 260 261 func TestMantleBaseURLExplicitOverridesRegion(t *testing.T) { 262 t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") 263 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 264 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 265 266 resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{ 267 BaseURL: "https://explicit.example.com", 268 AWSRegion: "us-east-1", 269 APIKey: "my-key", 270 }), mantleResolveParams()) 271 if err != nil { 272 t.Fatalf("unexpected error: %v", err) 273 } 274 if resolved.BaseURL != "https://explicit.example.com" { 275 t.Errorf("expected explicit base URL, got %q", resolved.BaseURL) 276 } 277 } 278 279 // --- skipAuth tests --- 280 281 func TestMantleSkipAuthNoAuthRequired(t *testing.T) { 282 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 283 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 284 285 _, err := NewMantleClient(context.Background(), MantleClientConfig{ 286 BaseURL: "https://proxy.example.com", 287 SkipAuth: true, 288 }) 289 if err != nil { 290 t.Fatalf("expected no error with skipAuth, got: %v", err) 291 } 292 } 293 294 func TestMantleSkipAuthNoAuthHeaders(t *testing.T) { 295 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 296 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 297 298 client, captured := newTestMantleClient(t, MantleClientConfig{ 299 SkipAuth: true, 300 }) 301 sendTestMantleRequest(t, client) 302 303 if captured.Headers.Get("Authorization") != "" { 304 t.Error("expected no Authorization header with skipAuth") 305 } 306 if captured.Headers.Get("X-Amz-Date") != "" { 307 t.Error("expected no X-Amz-Date header with skipAuth") 308 } 309 } 310 311 // --- NewMantleClient tests --- 312 313 func TestNewMantleClientMessages(t *testing.T) { 314 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 315 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 316 317 client, captured := newTestMantleClient(t, MantleClientConfig{ 318 APIKey: "test-key", 319 AWSRegion: "us-east-1", 320 }) 321 sendTestMantleRequest(t, client) 322 323 if got := captured.Headers.Get("Authorization"); got != "Bearer test-key" { 324 t.Errorf("expected Authorization %q, got %q", "Bearer test-key", got) 325 } 326 } 327 328 func TestNewMantleClientBetaMessages(t *testing.T) { 329 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 330 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 331 332 var captured mantleCapturedRequest 333 server := httptest.NewServer(mantleMessagesHandler(&captured)) 334 t.Cleanup(server.Close) 335 336 client, err := NewMantleClient(context.Background(), MantleClientConfig{ 337 APIKey: "test-key", 338 AWSRegion: "us-east-1", 339 BaseURL: server.URL, 340 }) 341 if err != nil { 342 t.Fatalf("unexpected error: %v", err) 343 } 344 345 _, err = client.Beta.Messages.New(context.Background(), anthropic.BetaMessageNewParams{ 346 Model: "claude-sonnet-4-6-20250514", 347 MaxTokens: 1, 348 Messages: []anthropic.BetaMessageParam{ 349 anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("hi")), 350 }, 351 }) 352 if err != nil { 353 t.Fatalf("beta messages request failed: %v", err) 354 } 355 } 356 357 // --- RequestOption tests --- 358 359 func TestMantleClientRequestOptionsCustomHeader(t *testing.T) { 360 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 361 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 362 363 client, captured := newTestMantleClient(t, MantleClientConfig{ 364 APIKey: "test-key", 365 AWSRegion: "us-east-1", 366 }, option.WithHeader("X-Custom-Header", "custom-value")) 367 sendTestMantleRequest(t, client) 368 369 if got := captured.Headers.Get("X-Custom-Header"); got != "custom-value" { 370 t.Errorf("expected X-Custom-Header %q, got %q", "custom-value", got) 371 } 372 } 373 374 func TestMantleClientRequestOptionsOverrideInternal(t *testing.T) { 375 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 376 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 377 378 // User-provided opts should override internal opts (e.g. override the Authorization header) 379 client, captured := newTestMantleClient(t, MantleClientConfig{ 380 APIKey: "internal-key", 381 AWSRegion: "us-east-1", 382 }, option.WithHeader("Authorization", "Bearer override-key")) 383 sendTestMantleRequest(t, client) 384 385 if got := captured.Headers.Get("Authorization"); got != "Bearer override-key" { 386 t.Errorf("expected Authorization %q (from RequestOption override), got %q", "Bearer override-key", got) 387 } 388 } 389 390 func TestMantleClientPerRequestOptionsOverrideClientOptions(t *testing.T) { 391 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 392 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 393 394 client, captured := newTestMantleClient(t, MantleClientConfig{ 395 APIKey: "test-key", 396 AWSRegion: "us-east-1", 397 }, option.WithHeader("X-Custom-Header", "client-level")) 398 399 // Send request with per-request option that overrides the client-level header 400 _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ 401 Model: "claude-sonnet-4-6-20250514", 402 MaxTokens: 1, 403 Messages: []anthropic.MessageParam{ 404 anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), 405 }, 406 }, option.WithHeader("X-Custom-Header", "request-level")) 407 if err != nil { 408 t.Fatalf("request failed: %v", err) 409 } 410 411 if got := captured.Headers.Get("X-Custom-Header"); got != "request-level" { 412 t.Errorf("expected X-Custom-Header %q (per-request override), got %q", "request-level", got) 413 } 414 } 415 416 // TestMantleUserMiddlewareRunsBeforeSigV4Signing verifies that SigV4 signing 417 // runs after (inside) middleware registered through client options, so the 418 // signature covers the middleware's request mutations. 419 func TestMantleUserMiddlewareRunsBeforeSigV4Signing(t *testing.T) { 420 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 421 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 422 t.Setenv("ANTHROPIC_API_KEY", "") 423 424 var observedAuth, observedAmzDate string 425 mutator := func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { 426 observedAuth = r.Header.Get("Authorization") 427 observedAmzDate = r.Header.Get("X-Amz-Date") 428 429 body, err := io.ReadAll(r.Body) 430 if err != nil { 431 return nil, err 432 } 433 var parsed map[string]any 434 if err := json.Unmarshal(body, &parsed); err != nil { 435 return nil, err 436 } 437 parsed["metadata"] = map[string]any{"user_id": "injected-by-middleware"} 438 mutated, err := json.Marshal(parsed) 439 if err != nil { 440 return nil, err 441 } 442 r.Body = io.NopCloser(bytes.NewReader(mutated)) 443 r.ContentLength = int64(len(mutated)) 444 return next(r) 445 } 446 447 var wireBody map[string]any 448 var wireAuth string 449 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 450 wireAuth = r.Header.Get("Authorization") 451 if err := json.NewDecoder(r.Body).Decode(&wireBody); err != nil { 452 t.Errorf("failed to decode wire body: %v", err) 453 } 454 writeMessagesResponse(w) 455 })) 456 t.Cleanup(server.Close) 457 458 client, err := NewMantleClient(context.Background(), MantleClientConfig{ 459 BaseURL: server.URL, 460 AWSRegion: "us-east-1", 461 AWSAccessKey: "test-access-key", 462 AWSSecretAccessKey: "test-secret-key", 463 }, option.WithMiddleware(mutator)) 464 if err != nil { 465 t.Fatalf("NewMantleClient failed: %v", err) 466 } 467 sendTestMantleRequest(t, client) 468 469 // The user middleware runs before signing, so no signature exists yet. 470 if observedAuth != "" { 471 t.Errorf("expected user middleware to run before signing, but saw Authorization %q", observedAuth) 472 } 473 if observedAmzDate != "" { 474 t.Errorf("expected user middleware to run before signing, but saw X-Amz-Date %q", observedAmzDate) 475 } 476 477 // The wire request is signed and carries the middleware's mutation. 478 if !strings.HasPrefix(wireAuth, "AWS4-HMAC-SHA256") { 479 t.Errorf("expected SigV4 Authorization on the wire, got %q", wireAuth) 480 } 481 metadata, _ := wireBody["metadata"].(map[string]any) 482 if metadata["user_id"] != "injected-by-middleware" { 483 t.Errorf("expected middleware mutation in the signed wire body, got metadata %v", wireBody["metadata"]) 484 } 485 } 486 487 func TestMantleClientRequestOptionsMiddleware(t *testing.T) { 488 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 489 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 490 491 middlewareCalled := false 492 mw := func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { 493 middlewareCalled = true 494 r.Header.Set("X-From-Middleware", "true") 495 return next(r) 496 } 497 498 client, captured := newTestMantleClient(t, MantleClientConfig{ 499 APIKey: "test-key", 500 AWSRegion: "us-east-1", 501 }, option.WithMiddleware(mw)) 502 sendTestMantleRequest(t, client) 503 504 if !middlewareCalled { 505 t.Error("expected middleware to be called") 506 } 507 if got := captured.Headers.Get("X-From-Middleware"); got != "true" { 508 t.Errorf("expected X-From-Middleware %q, got %q", "true", got) 509 } 510 } 511 512 // --- ANTHROPIC_API_KEY isolation test --- 513 514 func TestMantleDoesNotLeakAnthropicAPIKey(t *testing.T) { 515 t.Setenv("ANTHROPIC_API_KEY", "should-not-appear") 516 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") 517 t.Setenv("ANTHROPIC_AWS_API_KEY", "") 518 519 client, captured := newTestMantleClient(t, MantleClientConfig{ 520 AWSRegion: "us-east-1", 521 AWSAccessKey: "test-access-key", 522 AWSSecretAccessKey: "test-secret-key", 523 }) 524 sendTestMantleRequest(t, client) 525 526 if got := captured.Headers.Get("X-Api-Key"); got != "" { 527 t.Errorf("expected no X-Api-Key header when using SigV4, got %q", got) 528 } 529 // Authorization header should be SigV4, not Bearer 530 if auth := captured.Headers.Get("Authorization"); !strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { 531 t.Errorf("expected SigV4 Authorization header, got %q", auth) 532 } 533 }