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 }