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

     1  package vertex
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  
    10  	"golang.org/x/oauth2"
    11  	"golang.org/x/oauth2/google"
    12  	"google.golang.org/api/option"
    13  	"google.golang.org/api/transport"
    14  
    15  	"github.com/anthropics/anthropic-sdk-go/internal/requestconfig"
    16  	sdkoption "github.com/anthropics/anthropic-sdk-go/option"
    17  	"github.com/tidwall/gjson"
    18  	"github.com/tidwall/sjson"
    19  )
    20  
    21  const DefaultVersion = "vertex-2023-10-16"
    22  
    23  // cloudPlatformScope is the OAuth2 scope used when the caller provides none.
    24  // External-account (workload identity federation) credentials fail to mint
    25  // tokens without an explicit scope.
    26  const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
    27  
    28  // WithGoogleAuth returns a request option which loads the [Application Default Credentials] for Google Vertex AI and registers
    29  // middleware that intercepts requests to the Messages API.
    30  //
    31  // When no scopes are given, the https://www.googleapis.com/auth/cloud-platform
    32  // scope is used.
    33  //
    34  // If you already have a [*google.Credentials], it is recommended that you instead call [WithCredentials] directly.
    35  //
    36  // Register any [sdkoption.WithMiddleware] before this option so your
    37  // middleware observes Anthropic-shaped requests; see [WithCredentials].
    38  //
    39  // Like [WithCredentials], the returned option includes
    40  // [sdkoption.WithoutEnvironmentDefaults], so first-party credential sources
    41  // never apply to a client constructed with it.
    42  //
    43  // [Application Default Credentials]: https://cloud.google.com/docs/authentication/application-default-credentials
    44  func WithGoogleAuth(ctx context.Context, region string, projectID string, scopes ...string) sdkoption.RequestOption {
    45  	if region == "" {
    46  		panic("region must be provided")
    47  	}
    48  	if len(scopes) == 0 {
    49  		scopes = []string{cloudPlatformScope}
    50  	}
    51  	creds, err := google.FindDefaultCredentials(ctx, scopes...)
    52  	if err != nil {
    53  		panic(fmt.Errorf("failed to find default credentials: %v", err))
    54  	}
    55  	return WithCredentials(ctx, region, projectID, creds)
    56  }
    57  
    58  // WithCredentials returns a request option which uses the provided credentials for Google Vertex AI and registers middleware that
    59  // intercepts request to the Messages API.
    60  //
    61  // External-account (workload identity federation) credentials must be
    62  // constructed with a scope — typically
    63  // https://www.googleapis.com/auth/cloud-platform — or token minting fails.
    64  //
    65  // The Vertex adaptation (URL and body rewriting, OAuth authorization) should
    66  // run closest to the wire. Middleware runs in registration order, so register
    67  // [sdkoption.WithMiddleware] before this option:
    68  //
    69  //	client := anthropic.NewClient(
    70  //		option.WithMiddleware(loggingMiddleware),
    71  //		vertex.WithCredentials(ctx, region, projectID, creds),
    72  //	)
    73  //
    74  // Ordered this way, your middleware observes Anthropic-shaped requests
    75  // (POST /v1/messages with the model in the body) — identical to the
    76  // first-party API.
    77  //
    78  // A custom [sdkoption.WithHTTPClient] is honored when it is passed before this
    79  // option: its transport is wrapped with OAuth authorization and its other
    80  // settings (Timeout, CheckRedirect, Jar) are preserved. Passed after this
    81  // option, it replaces the Vertex-configured client entirely.
    82  //
    83  // The returned option is an [sdkoption.Join] of
    84  // [sdkoption.WithoutEnvironmentDefaults] and the Vertex configuration, so a
    85  // client constructed with it skips the SDK's first-party credential autoload
    86  // entirely: environment credentials and profiles from the shared config
    87  // store (ANTHROPIC_API_KEY, ANTHROPIC_PROFILE, ANTHROPIC_CONFIG_DIR) never
    88  // apply to Vertex requests.
    89  func WithCredentials(ctx context.Context, region string, projectID string, creds *google.Credentials) sdkoption.RequestOption {
    90  	defaultClient, _, err := transport.NewHTTPClient(ctx, option.WithTokenSource(creds.TokenSource))
    91  	if err != nil {
    92  		panic(fmt.Errorf("failed to create HTTP client: %v", err))
    93  	}
    94  	middleware := vertexMiddleware(region, projectID)
    95  
    96  	var baseURL string
    97  	switch region {
    98  	case "global":
    99  		baseURL = "https://aiplatform.googleapis.com/"
   100  	case "us":
   101  		baseURL = "https://aiplatform.us.rep.googleapis.com/"
   102  	case "eu":
   103  		baseURL = "https://aiplatform.eu.rep.googleapis.com/"
   104  	default:
   105  		baseURL = fmt.Sprintf("https://%s-aiplatform.googleapis.com/", region)
   106  	}
   107  
   108  	return sdkoption.Join(sdkoption.WithoutEnvironmentDefaults(), requestconfig.RequestOptionFunc(func(rc *requestconfig.RequestConfig) error {
   109  		client := defaultClient
   110  		if rc.HTTPClient != nil && rc.HTTPClient != http.DefaultClient {
   111  			client = authorizeClient(rc.HTTPClient, creds)
   112  		}
   113  		return rc.Apply(
   114  			sdkoption.WithBaseURL(baseURL),
   115  			sdkoption.WithMiddleware(middleware),
   116  			sdkoption.WithHTTPClient(client),
   117  		)
   118  	}))
   119  }
   120  
   121  // authorizeClient returns a shallow copy of client whose transport attaches
   122  // OAuth authorization from creds; the caller's client is not mutated. Only
   123  // authorization is layered on — a user-supplied transport is otherwise used
   124  // as-is.
   125  func authorizeClient(client *http.Client, creds *google.Credentials) *http.Client {
   126  	base := client.Transport
   127  	if base == nil {
   128  		base = http.DefaultTransport
   129  	}
   130  	if authed, ok := base.(*oauth2.Transport); ok && authed.Source == creds.TokenSource {
   131  		// Already wrapped by an earlier apply of this option.
   132  		return client
   133  	}
   134  	wrapped := *client
   135  	wrapped.Transport = &oauth2.Transport{Base: base, Source: creds.TokenSource}
   136  	return &wrapped
   137  }
   138  
   139  func vertexMiddleware(region, projectID string) sdkoption.Middleware {
   140  	return func(r *http.Request, next sdkoption.MiddlewareNext) (*http.Response, error) {
   141  		if r.Body != nil {
   142  			body, err := io.ReadAll(r.Body)
   143  			if err != nil {
   144  				return nil, err
   145  			}
   146  			r.Body.Close()
   147  
   148  			// Raw (non-JSON) bodies must reach the wire byte-for-byte; only a
   149  			// JSON body carries fields to inject or strip.
   150  			isJSON := gjson.ValidBytes(body)
   151  
   152  			if isJSON && !gjson.GetBytes(body, "anthropic_version").Exists() {
   153  				body, _ = sjson.SetBytes(body, "anthropic_version", DefaultVersion)
   154  			}
   155  
   156  			if isJSON && r.URL.Path == "/v1/messages" && r.Method == http.MethodPost {
   157  				if projectID == "" {
   158  					return nil, fmt.Errorf("no projectId was given and it could not be resolved from credentials")
   159  				}
   160  
   161  				model := gjson.GetBytes(body, "model").String()
   162  				stream := gjson.GetBytes(body, "stream").Bool()
   163  
   164  				body, _ = sjson.DeleteBytes(body, "model")
   165  
   166  				specifier := "rawPredict"
   167  				if stream {
   168  					specifier = "streamRawPredict"
   169  				}
   170  
   171  				r.URL.Path = fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", projectID, region, model, specifier)
   172  			}
   173  
   174  			if isJSON && r.URL.Path == "/v1/messages/count_tokens" && r.Method == http.MethodPost {
   175  				if projectID == "" {
   176  					return nil, fmt.Errorf("no projectId was given and it could not be resolved from credentials")
   177  				}
   178  
   179  				r.URL.Path = fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/count-tokens:rawPredict", projectID, region)
   180  			}
   181  
   182  			reader := bytes.NewReader(body)
   183  			r.Body = io.NopCloser(reader)
   184  			r.GetBody = func() (io.ReadCloser, error) {
   185  				_, err := reader.Seek(0, 0)
   186  				return io.NopCloser(reader), err
   187  			}
   188  			r.ContentLength = int64(len(body))
   189  		}
   190  
   191  		return next(r)
   192  	}
   193  }