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  }