github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/awsauth/awsauth.go (about) 1 package awsauth 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/sha256" 7 "encoding/hex" 8 "fmt" 9 "io" 10 "net/http" 11 "os" 12 "time" 13 14 awssdk "github.com/aws/aws-sdk-go-v2/aws" 15 v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" 16 "github.com/aws/aws-sdk-go-v2/config" 17 "github.com/aws/aws-sdk-go-v2/credentials" 18 19 "github.com/anthropics/anthropic-sdk-go/option" 20 ) 21 22 // ClientConfig holds the configuration for creating an Anthropic client that authenticates 23 // via AWS credentials. This is the internal representation used by both the aws and bedrock 24 // mantle packages. 25 type ClientConfig struct { 26 APIKey string 27 AWSAccessKey string 28 AWSSecretAccessKey string 29 AWSSessionToken string 30 AWSProfile string 31 AWSRegion string 32 WorkspaceID string 33 BaseURL string 34 SkipAuth bool 35 } 36 37 // ResolveParams customizes config resolution per client (env var names, base URL derivation, service name). 38 type ResolveParams struct { 39 // EnvAPIKey is the primary environment variable for the API key (e.g. "ANTHROPIC_AWS_API_KEY"). 40 EnvAPIKey string 41 42 // EnvAPIKeyFallback is the fallback environment variable for the API key. 43 // Used by Bedrock Mantle to fall back to the AWS API key env var. 44 EnvAPIKeyFallback string 45 46 // EnvWorkspaceID is the primary environment variable for the workspace ID. 47 EnvWorkspaceID string 48 49 // EnvWorkspaceIDFallback is the fallback environment variable for the workspace ID. 50 EnvWorkspaceIDFallback string 51 52 // EnvBaseURL is the environment variable for the base URL override. 53 EnvBaseURL string 54 55 // DeriveBaseURL is called with the resolved region to produce a default base URL 56 // when neither the config field nor the env var is set. 57 DeriveBaseURL func(region string) string 58 59 // ServiceName is the AWS service name used in SigV4 signing. 60 ServiceName string 61 62 // UseBearerAuth, when true, sends the API key as an Authorization: Bearer header 63 // instead of the default X-Api-Key header. 64 UseBearerAuth bool 65 } 66 67 // ResolvedConfig holds the fully resolved configuration after applying defaults and env vars. 68 type ResolvedConfig struct { 69 Region string 70 BaseURL string 71 WorkspaceID string 72 APIKey string 73 UseSigV4 bool 74 SkipAuth bool 75 } 76 77 // ResolveConfig resolves all configuration values from ClientConfig fields and 78 // environment variable fallbacks, parameterized by ResolveParams. 79 func ResolveConfig(cfg ClientConfig, params ResolveParams) (ResolvedConfig, error) { 80 var rc ResolvedConfig 81 82 // Region: arg > AWS_REGION env > AWS_DEFAULT_REGION env 83 rc.Region = cfg.AWSRegion 84 if rc.Region == "" { 85 rc.Region = os.Getenv("AWS_REGION") 86 } 87 if rc.Region == "" { 88 rc.Region = os.Getenv("AWS_DEFAULT_REGION") 89 } 90 91 // Base URL: arg > env var > derived from region 92 rc.BaseURL = cfg.BaseURL 93 if rc.BaseURL == "" && params.EnvBaseURL != "" { 94 rc.BaseURL = os.Getenv(params.EnvBaseURL) 95 } 96 if rc.BaseURL == "" && rc.Region != "" && params.DeriveBaseURL != nil { 97 rc.BaseURL = params.DeriveBaseURL(rc.Region) 98 } 99 100 rc.SkipAuth = cfg.SkipAuth 101 102 // Workspace ID: arg > primary env > fallback env 103 rc.WorkspaceID = cfg.WorkspaceID 104 if rc.WorkspaceID == "" && params.EnvWorkspaceID != "" { 105 rc.WorkspaceID = os.Getenv(params.EnvWorkspaceID) 106 } 107 if rc.WorkspaceID == "" && params.EnvWorkspaceIDFallback != "" { 108 rc.WorkspaceID = os.Getenv(params.EnvWorkspaceIDFallback) 109 } 110 // Workspace ID is required when env var names are configured (i.e. the caller expects it) 111 if rc.WorkspaceID == "" && !rc.SkipAuth && (params.EnvWorkspaceID != "" || cfg.WorkspaceID != "") { 112 envHint := params.EnvWorkspaceID 113 if envHint == "" { 114 envHint = "ANTHROPIC_AWS_WORKSPACE_ID" 115 } 116 return rc, fmt.Errorf("no workspace ID found; set WorkspaceID in ClientConfig or set the %s environment variable", envHint) 117 } 118 119 if !rc.SkipAuth { 120 // Auth mode resolution 121 switch { 122 case cfg.APIKey != "": 123 rc.APIKey = cfg.APIKey 124 case (cfg.AWSAccessKey != "" && cfg.AWSSecretAccessKey != "") || cfg.AWSProfile != "": 125 rc.UseSigV4 = true 126 default: 127 envKey := envLookup(params.EnvAPIKey, params.EnvAPIKeyFallback) 128 if envKey != "" { 129 rc.APIKey = envKey 130 } else { 131 rc.UseSigV4 = true 132 } 133 } 134 135 if rc.UseSigV4 && rc.Region == "" { 136 return rc, fmt.Errorf("no AWS region found; set AWSRegion in ClientConfig or set the AWS_REGION environment variable") 137 } 138 } 139 140 if rc.BaseURL == "" { 141 envHint := params.EnvBaseURL 142 if envHint == "" { 143 envHint = "ANTHROPIC_AWS_BASE_URL" 144 } 145 return rc, fmt.Errorf("no base URL found; set BaseURL or AWSRegion in ClientConfig, or set the %s or AWS_REGION environment variable", envHint) 146 } 147 148 return rc, nil 149 } 150 151 // envLookup returns the first non-empty value from the given environment variable names. 152 func envLookup(names ...string) string { 153 for _, name := range names { 154 if name != "" { 155 if v := os.Getenv(name); v != "" { 156 return v 157 } 158 } 159 } 160 return "" 161 } 162 163 // BuildAWSConfig creates an [awssdk.Config] from explicit credentials or the default 164 // AWS credential chain. 165 func BuildAWSConfig(ctx context.Context, cfg ClientConfig, region string) (awssdk.Config, error) { 166 if cfg.AWSAccessKey != "" && cfg.AWSSecretAccessKey != "" { 167 return awssdk.Config{ 168 Region: region, 169 Credentials: credentials.StaticCredentialsProvider{ 170 Value: awssdk.Credentials{ 171 AccessKeyID: cfg.AWSAccessKey, 172 SecretAccessKey: cfg.AWSSecretAccessKey, 173 SessionToken: cfg.AWSSessionToken, 174 }, 175 }, 176 }, nil 177 } 178 179 loadOpts := []func(*config.LoadOptions) error{ 180 config.WithRegion(region), 181 } 182 if cfg.AWSProfile != "" { 183 loadOpts = append(loadOpts, config.WithSharedConfigProfile(cfg.AWSProfile)) 184 } 185 awsCfg, err := config.LoadDefaultConfig(ctx, loadOpts...) 186 if err != nil { 187 return awssdk.Config{}, fmt.Errorf("failed to load AWS config: %w", err) 188 } 189 190 // Eagerly verify that credentials can be resolved so callers get a clear 191 // error at setup time rather than on the first request. 192 if _, err := awsCfg.Credentials.Retrieve(ctx); err != nil { 193 return awssdk.Config{}, fmt.Errorf("failed to resolve AWS credentials: %w", err) 194 } 195 196 return awsCfg, nil 197 } 198 199 // SigV4Middleware returns an HTTP middleware that signs requests using AWS SigV4 200 // with the given service name. 201 func SigV4Middleware(signer *v4.Signer, cfg awssdk.Config, serviceName string) option.Middleware { 202 return func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { 203 var body []byte 204 var err error 205 206 if r.Body != nil { 207 body, err = io.ReadAll(r.Body) 208 if err != nil { 209 return nil, err 210 } 211 r.Body.Close() 212 } 213 214 // r.Body and GetBody share a single reader. This is safe because the SDK's 215 // retry logic calls GetBody (which seeks to 0) before re-reading the body, 216 // and these operations are sequential within a single goroutine. 217 reader := bytes.NewReader(body) 218 r.Body = io.NopCloser(reader) 219 r.GetBody = func() (io.ReadCloser, error) { 220 _, err := reader.Seek(0, 0) 221 return io.NopCloser(reader), err 222 } 223 r.ContentLength = int64(len(body)) 224 225 ctx := r.Context() 226 creds, err := cfg.Credentials.Retrieve(ctx) 227 if err != nil { 228 return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) 229 } 230 231 hash := sha256.Sum256(body) 232 if err = signer.SignHTTP(ctx, creds, r, hex.EncodeToString(hash[:]), serviceName, cfg.Region, time.Now()); err != nil { 233 return nil, fmt.Errorf("failed to sign request: %w", err) 234 } 235 236 return next(r) 237 } 238 } 239 240 // CreateClientOptions returns request options that configure an Anthropic client for use 241 // with an AWS-based service. The params argument customizes env var names, base URL derivation, 242 // and SigV4 service name per client type. 243 // 244 // Caller-provided userOpts are placed after the base options (so they can 245 // override the base URL, headers, etc.) but before the SigV4 signing 246 // middleware: signing must run closest to the wire so the signature covers 247 // any body or header mutations made by user middleware. 248 func CreateClientOptions(ctx context.Context, cfg ClientConfig, params ResolveParams, userOpts ...option.RequestOption) ([]option.RequestOption, error) { 249 resolved, err := ResolveConfig(cfg, params) 250 if err != nil { 251 return nil, err 252 } 253 254 var opts []option.RequestOption 255 256 if resolved.BaseURL != "" { 257 opts = append(opts, option.WithBaseURL(resolved.BaseURL)) 258 } 259 260 if resolved.SkipAuth { 261 return append(opts, userOpts...), nil 262 } 263 264 if resolved.APIKey != "" { 265 if params.UseBearerAuth { 266 opts = append(opts, option.WithHeader("Authorization", "Bearer "+resolved.APIKey)) 267 } else { 268 opts = append(opts, option.WithAPIKey(resolved.APIKey)) 269 } 270 } 271 if resolved.WorkspaceID != "" { 272 opts = append(opts, option.WithHeader("anthropic-workspace-id", resolved.WorkspaceID)) 273 } 274 275 opts = append(opts, userOpts...) 276 277 if resolved.UseSigV4 { 278 awsCfg, err := BuildAWSConfig(ctx, cfg, resolved.Region) 279 if err != nil { 280 return nil, err 281 } 282 283 signer := v4.NewSigner() 284 opts = append(opts, option.WithMiddleware(SigV4Middleware(signer, awsCfg, params.ServiceName))) 285 } 286 287 return opts, nil 288 }