github.com/anthropics/anthropic-sdk-go@v1.70.1/googlecloud/auth.go (about)

     1  // Config resolution and bearer-token auth for the Anthropic Google Cloud client.
     2  
     3  package googlecloud
     4  
     5  import (
     6  	"context"
     7  	"fmt"
     8  	"net/http"
     9  	"os"
    10  
    11  	"golang.org/x/oauth2"
    12  	"golang.org/x/oauth2/google"
    13  
    14  	"github.com/anthropics/anthropic-sdk-go/option"
    15  )
    16  
    17  // Environment variables consulted as fallbacks for the corresponding
    18  // [ClientConfig] fields.
    19  const (
    20  	envProject     = "ANTHROPIC_GOOGLE_CLOUD_PROJECT"
    21  	envLocation    = "ANTHROPIC_GOOGLE_CLOUD_LOCATION"
    22  	envWorkspaceID = "ANTHROPIC_GOOGLE_CLOUD_WORKSPACE_ID"
    23  	envBaseURL     = "ANTHROPIC_GOOGLE_CLOUD_BASE_URL"
    24  )
    25  
    26  // cloudPlatformScope is the OAuth2 scope required to call the gateway.
    27  const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
    28  
    29  // baseURLTemplate is the gateway base URL; override via BaseURL if needed.
    30  // %[1]s is the project, %[2]s the location, %[3]s the workspace ID.
    31  const baseURLTemplate = "https://claude.googleapis.com/v1alpha/projects/%[1]s/locations/%[2]s/workspaces/%[3]s/invoke"
    32  
    33  // defaultLocation is the location used when none is configured; the gateway
    34  // should always be addressed via the global region.
    35  const defaultLocation = "global"
    36  
    37  // resolveConfig applies environment-variable fallbacks and derives the base URL
    38  // when a project is known. It does not touch the network; ADC-based project
    39  // backfill happens in createClientOptions. Location defaults to "global" when
    40  // not configured. The workspace ID is embedded in the derived base URL, so it
    41  // is required unless SkipAuth is set together with an explicit BaseURL.
    42  func resolveConfig(cfg ClientConfig) (ClientConfig, error) {
    43  	cfg.Project = firstNonEmpty(cfg.Project, os.Getenv(envProject), os.Getenv("GOOGLE_CLOUD_PROJECT"))
    44  	cfg.Location = firstNonEmpty(cfg.Location, os.Getenv(envLocation), defaultLocation)
    45  	cfg.BaseURL = firstNonEmpty(cfg.BaseURL, os.Getenv(envBaseURL))
    46  	cfg.WorkspaceID = firstNonEmpty(cfg.WorkspaceID, os.Getenv(envWorkspaceID))
    47  
    48  	if cfg.SkipAuth && cfg.TokenSource != nil {
    49  		return cfg, fmt.Errorf("googlecloud: SkipAuth and TokenSource are mutually exclusive; set one or the other")
    50  	}
    51  
    52  	// The workspace ID is required unless SkipAuth is set together with an
    53  	// explicit BaseURL (no URL to derive).
    54  	if cfg.WorkspaceID == "" && !(cfg.SkipAuth && cfg.BaseURL != "") {
    55  		return cfg, fmt.Errorf("googlecloud: no workspace ID found; set WorkspaceID or the %s environment variable", envWorkspaceID)
    56  	}
    57  
    58  	if cfg.BaseURL == "" && cfg.Project != "" {
    59  		cfg.BaseURL = deriveBaseURL(cfg.Project, cfg.Location, cfg.WorkspaceID)
    60  	}
    61  
    62  	return cfg, nil
    63  }
    64  
    65  // createClientOptions resolves configuration and returns the request options that
    66  // configure an Anthropic client for the Google Cloud gateway: the base URL
    67  // (which embeds the workspace ID) and a Google bearer-token middleware.
    68  func createClientOptions(ctx context.Context, cfg ClientConfig) ([]option.RequestOption, error) {
    69  	resolved, err := resolveConfig(cfg)
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  
    74  	// Resolve credentials first: ADC also yields a project we can use to backfill
    75  	// both an unset Project and the derived base URL.
    76  	var ts oauth2.TokenSource
    77  	if !resolved.SkipAuth {
    78  		ts = resolved.TokenSource
    79  		if ts == nil {
    80  			creds, err := google.FindDefaultCredentials(ctx, cloudPlatformScope)
    81  			if err != nil {
    82  				return nil, fmt.Errorf("googlecloud: failed to load Google application default credentials: %w", err)
    83  			}
    84  			ts = creds.TokenSource
    85  			if resolved.Project == "" {
    86  				resolved.Project = creds.ProjectID
    87  			}
    88  		}
    89  		// Guarantee the per-request caching the middleware relies on, regardless of
    90  		// what the caller passed (ADC's source already caches; a naive caller source
    91  		// would otherwise be hit on every request and retry).
    92  		ts = oauth2.ReuseTokenSource(nil, ts)
    93  	}
    94  
    95  	if resolved.BaseURL == "" {
    96  		if resolved.Project == "" {
    97  			return nil, fmt.Errorf("googlecloud: no project found; set Project, set the %s environment variable, or configure application default credentials with a project", envProject)
    98  		}
    99  		resolved.BaseURL = deriveBaseURL(resolved.Project, resolved.Location, resolved.WorkspaceID)
   100  	}
   101  
   102  	opts := []option.RequestOption{option.WithBaseURL(resolved.BaseURL)}
   103  
   104  	if resolved.SkipAuth {
   105  		return opts, nil
   106  	}
   107  
   108  	opts = append(opts, option.WithMiddleware(bearerMiddleware(ts)))
   109  	return opts, nil
   110  }
   111  
   112  // bearerMiddleware attaches a Google OAuth2 access token to each request as
   113  // Authorization: Bearer, only if the header is not already set. The token source
   114  // caches and refreshes internally, so this is cheap per request. Unlike SigV4, a
   115  // bearer token does not depend on the request body, so the body is never read or
   116  // buffered here — streaming and multipart uploads pass through untouched.
   117  func bearerMiddleware(ts oauth2.TokenSource) option.Middleware {
   118  	return func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) {
   119  		if r.Header.Get("Authorization") == "" {
   120  			tok, err := ts.Token()
   121  			if err != nil {
   122  				return nil, fmt.Errorf("googlecloud: failed to fetch Google access token: %w", err)
   123  			}
   124  			tok.SetAuthHeader(r)
   125  		}
   126  		return next(r)
   127  	}
   128  }
   129  
   130  func deriveBaseURL(project, location, workspaceID string) string {
   131  	return fmt.Sprintf(baseURLTemplate, project, location, workspaceID)
   132  }
   133  
   134  func firstNonEmpty(vals ...string) string {
   135  	for _, v := range vals {
   136  		if v != "" {
   137  			return v
   138  		}
   139  	}
   140  	return ""
   141  }