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

     1  package vertex
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"testing"
    15  	"time"
    16  
    17  	"golang.org/x/oauth2"
    18  	"golang.org/x/oauth2/google"
    19  
    20  	"github.com/anthropics/anthropic-sdk-go"
    21  	"github.com/anthropics/anthropic-sdk-go/internal/requestconfig"
    22  	sdkoption "github.com/anthropics/anthropic-sdk-go/option"
    23  )
    24  
    25  func TestBaseURLForRegion(t *testing.T) {
    26  	testCases := []struct {
    27  		name        string
    28  		region      string
    29  		expectedURL string
    30  	}{
    31  		{
    32  			name:        "global region",
    33  			region:      "global",
    34  			expectedURL: "https://aiplatform.googleapis.com/",
    35  		},
    36  		{
    37  			name:        "us region",
    38  			region:      "us",
    39  			expectedURL: "https://aiplatform.us.rep.googleapis.com/",
    40  		},
    41  		{
    42  			name:        "specific region",
    43  			region:      "us-central1",
    44  			expectedURL: "https://us-central1-aiplatform.googleapis.com/",
    45  		},
    46  		{
    47  			name:        "eu region",
    48  			region:      "eu",
    49  			expectedURL: "https://aiplatform.eu.rep.googleapis.com/",
    50  		},
    51  		{
    52  			name:        "specific european region",
    53  			region:      "europe-west1",
    54  			expectedURL: "https://europe-west1-aiplatform.googleapis.com/",
    55  		},
    56  	}
    57  
    58  	for _, tc := range testCases {
    59  		t.Run(tc.name, func(t *testing.T) {
    60  			creds := &google.Credentials{
    61  				TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake"}),
    62  			}
    63  			opt := WithCredentials(context.Background(), tc.region, "test-project", creds)
    64  
    65  			cfg := &requestconfig.RequestConfig{}
    66  			if err := opt.Apply(cfg); err != nil {
    67  				t.Fatalf("Failed to apply option: %v", err)
    68  			}
    69  
    70  			if cfg.BaseURL.String() != tc.expectedURL {
    71  				t.Errorf("Expected base URL %q, got %q", tc.expectedURL, cfg.BaseURL.String())
    72  			}
    73  		})
    74  	}
    75  }
    76  
    77  // TestVertexUserMiddlewareObservesAnthropicShape verifies the documented
    78  // ordering: middleware registered before the Vertex option observes the
    79  // Anthropic-shaped request, while the wire receives the rewritten Vertex
    80  // request with OAuth authorization.
    81  func TestVertexUserMiddlewareObservesAnthropicShape(t *testing.T) {
    82  	t.Setenv("ANTHROPIC_API_KEY", "")
    83  
    84  	var wirePath, wireAuth string
    85  	var wireBody map[string]any
    86  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    87  		wirePath = r.URL.Path
    88  		wireAuth = r.Header.Get("Authorization")
    89  		if err := json.NewDecoder(r.Body).Decode(&wireBody); err != nil {
    90  			t.Errorf("Failed to decode wire body: %v", err)
    91  		}
    92  		w.Header().Set("Content-Type", "application/json")
    93  		json.NewEncoder(w).Encode(map[string]any{
    94  			"id": "msg_test", "type": "message", "role": "assistant",
    95  			"content": []map[string]any{{"type": "text", "text": "hi"}},
    96  			"model":   "claude-3-sonnet", "stop_reason": "end_turn",
    97  			"usage": map[string]any{"input_tokens": 1, "output_tokens": 1},
    98  		})
    99  	}))
   100  	t.Cleanup(server.Close)
   101  
   102  	var observedPath, observedAuth string
   103  	var observedBody map[string]any
   104  	spy := func(r *http.Request, next sdkoption.MiddlewareNext) (*http.Response, error) {
   105  		observedPath = r.URL.Path
   106  		observedAuth = r.Header.Get("Authorization")
   107  		body, err := io.ReadAll(r.Body)
   108  		if err != nil {
   109  			return nil, err
   110  		}
   111  		if err := json.Unmarshal(body, &observedBody); err != nil {
   112  			return nil, err
   113  		}
   114  		r.Body = io.NopCloser(bytes.NewReader(body))
   115  		return next(r)
   116  	}
   117  
   118  	creds := &google.Credentials{
   119  		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake"}),
   120  	}
   121  	client := anthropic.NewClient(
   122  		sdkoption.WithoutEnvironmentDefaults(),
   123  		sdkoption.WithMiddleware(spy),
   124  		WithCredentials(context.Background(), "us-central1", "test-project", creds),
   125  		sdkoption.WithBaseURL(server.URL),
   126  	)
   127  
   128  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   129  		Model:     "claude-3-sonnet",
   130  		MaxTokens: 1,
   131  		Messages: []anthropic.MessageParam{
   132  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   133  		},
   134  	})
   135  	if err != nil {
   136  		t.Fatalf("Request failed: %v", err)
   137  	}
   138  
   139  	// The spy (outside the Vertex adaptation) sees the Anthropic shape,
   140  	// before OAuth authorization is attached at the transport.
   141  	if observedPath != "/v1/messages" {
   142  		t.Errorf("Expected middleware to observe path %q, got %q", "/v1/messages", observedPath)
   143  	}
   144  	if observedBody["model"] != "claude-3-sonnet" {
   145  		t.Errorf("Expected middleware to observe model in body, got %v", observedBody["model"])
   146  	}
   147  	if observedAuth != "" {
   148  		t.Errorf("Expected middleware to observe no Authorization header, got %q", observedAuth)
   149  	}
   150  
   151  	// The wire sees the rewritten, authorized Vertex shape.
   152  	expectedWirePath := "/v1/projects/test-project/locations/us-central1/publishers/anthropic/models/claude-3-sonnet:rawPredict"
   153  	if wirePath != expectedWirePath {
   154  		t.Errorf("Expected wire path %q, got %q", expectedWirePath, wirePath)
   155  	}
   156  	if _, ok := wireBody["model"]; ok {
   157  		t.Error("Expected model to be removed from the wire body")
   158  	}
   159  	if wireBody["anthropic_version"] != DefaultVersion {
   160  		t.Errorf("Expected anthropic_version %q on the wire, got %v", DefaultVersion, wireBody["anthropic_version"])
   161  	}
   162  	if wireAuth != "Bearer fake" {
   163  		t.Errorf("Expected OAuth Authorization on the wire, got %q", wireAuth)
   164  	}
   165  }
   166  
   167  // TestWithGoogleAuthDefaultsCloudPlatformScope drives the workload-identity
   168  // (external_account) flow against a local STS endpoint: without an explicit
   169  // scope from the caller, the token exchange must request cloud-platform —
   170  // external-account credentials fail to mint tokens with no scope at all.
   171  func TestWithGoogleAuthDefaultsCloudPlatformScope(t *testing.T) {
   172  	scopes := make(chan string, 1)
   173  	tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   174  		// The STS exchange is the only POST; the probe GET must not report.
   175  		if r.Method != http.MethodPost {
   176  			return
   177  		}
   178  		if err := r.ParseForm(); err != nil {
   179  			t.Errorf("Failed to parse token request form: %v", err)
   180  		}
   181  		scopes <- r.Form.Get("scope")
   182  		w.Header().Set("Content-Type", "application/json")
   183  		_, _ = w.Write([]byte(`{"access_token":"tok","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600}`))
   184  	}))
   185  	t.Cleanup(tokenServer.Close)
   186  
   187  	dir := t.TempDir()
   188  	subjectTokenFile := filepath.Join(dir, "subject-token")
   189  	if err := os.WriteFile(subjectTokenFile, []byte("subject-token"), 0600); err != nil {
   190  		t.Fatal(err)
   191  	}
   192  	credFile := filepath.Join(dir, "creds.json")
   193  	credJSON := fmt.Sprintf(`{
   194  		"type": "external_account",
   195  		"audience": "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/pool/providers/provider",
   196  		"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
   197  		"token_url": %q,
   198  		"credential_source": {"file": %q}
   199  	}`, tokenServer.URL, subjectTokenFile)
   200  	if err := os.WriteFile(credFile, []byte(credJSON), 0600); err != nil {
   201  		t.Fatal(err)
   202  	}
   203  	t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credFile)
   204  
   205  	var rc requestconfig.RequestConfig
   206  	if err := rc.Apply(WithGoogleAuth(context.Background(), "us-east5", "proj")); err != nil {
   207  		t.Fatalf("Expected option to apply, got: %v", err)
   208  	}
   209  
   210  	// Any request through the option's HTTP client first performs the token
   211  	// exchange; the probe target just needs to answer.
   212  	req, _ := http.NewRequest(http.MethodGet, tokenServer.URL+"/probe", nil)
   213  	resp, err := rc.HTTPClient.Do(req)
   214  	if err != nil {
   215  		t.Fatalf("Expected the probe request to complete, got: %v", err)
   216  	}
   217  	resp.Body.Close()
   218  
   219  	select {
   220  	case got := <-scopes:
   221  		if got != cloudPlatformScope {
   222  			t.Errorf("Expected default scope %q in the token exchange, got %q", cloudPlatformScope, got)
   223  		}
   224  	default:
   225  		t.Fatal("Expected a token exchange to hit the local STS endpoint")
   226  	}
   227  }
   228  
   229  type roundTripperFunc func(*http.Request) (*http.Response, error)
   230  
   231  func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
   232  
   233  // TestWithCredentialsPreservesUserHTTPClient is the regression test for a
   234  // user-supplied [sdkoption.WithHTTPClient] being replaced by the Vertex
   235  // option: the custom transport must carry the request, with OAuth
   236  // authorization layered on top and the Vertex URL rewrite still applied.
   237  func TestWithCredentialsPreservesUserHTTPClient(t *testing.T) {
   238  	const cannedMessage = `{"id":"msg_test","type":"message","role":"assistant",` +
   239  		`"content":[{"type":"text","text":"hi"}],"model":"claude-sonnet-4-5","stop_reason":"end_turn",` +
   240  		`"usage":{"input_tokens":1,"output_tokens":1}}`
   241  
   242  	// Only reachable if the user-supplied transport is bypassed.
   243  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   244  		t.Errorf("Expected the request to go through the user-supplied transport, but it reached the wire directly")
   245  		w.Header().Set("Content-Type", "application/json")
   246  		_, _ = w.Write([]byte(cannedMessage))
   247  	}))
   248  	t.Cleanup(server.Close)
   249  
   250  	var called bool
   251  	var gotAuth, gotURL string
   252  	recorder := roundTripperFunc(func(r *http.Request) (*http.Response, error) {
   253  		called = true
   254  		gotAuth = r.Header.Get("Authorization")
   255  		gotURL = r.URL.String()
   256  		return &http.Response{
   257  			StatusCode: http.StatusOK,
   258  			Header:     http.Header{"Content-Type": []string{"application/json"}},
   259  			Body:       io.NopCloser(strings.NewReader(cannedMessage)),
   260  			Request:    r,
   261  		}, nil
   262  	})
   263  	userClient := &http.Client{Transport: recorder, Timeout: 7 * time.Second}
   264  
   265  	creds := &google.Credentials{
   266  		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}),
   267  	}
   268  	client := anthropic.NewClient(
   269  		sdkoption.WithoutEnvironmentDefaults(),
   270  		sdkoption.WithHTTPClient(userClient),
   271  		WithCredentials(context.Background(), "us-east5", "proj", creds),
   272  		sdkoption.WithBaseURL(server.URL),
   273  	)
   274  
   275  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   276  		Model:     "claude-sonnet-4-5",
   277  		MaxTokens: 1,
   278  		Messages: []anthropic.MessageParam{
   279  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   280  		},
   281  	})
   282  	if err != nil {
   283  		t.Fatalf("Request failed: %v", err)
   284  	}
   285  
   286  	if !called {
   287  		t.Fatal("Expected the user-supplied transport to carry the request")
   288  	}
   289  	if gotAuth != "Bearer test-token" {
   290  		t.Errorf("Expected OAuth Authorization %q on the wire, got %q", "Bearer test-token", gotAuth)
   291  	}
   292  	expectedURL := server.URL + "/v1/projects/proj/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5:rawPredict"
   293  	if gotURL != expectedURL {
   294  		t.Errorf("Expected wire URL %q, got %q", expectedURL, gotURL)
   295  	}
   296  
   297  	// The applied client keeps the user's settings and the user's client is
   298  	// left untouched.
   299  	rc := requestconfig.RequestConfig{HTTPClient: userClient}
   300  	if err := rc.Apply(WithCredentials(context.Background(), "us-east5", "proj", creds)); err != nil {
   301  		t.Fatalf("Failed to apply option: %v", err)
   302  	}
   303  	if rc.HTTPClient == userClient {
   304  		t.Error("Expected a wrapped copy of the user client, got the same pointer")
   305  	}
   306  	if rc.HTTPClient.Timeout != 7*time.Second {
   307  		t.Errorf("Expected Timeout %v to be preserved, got %v", 7*time.Second, rc.HTTPClient.Timeout)
   308  	}
   309  	if _, ok := userClient.Transport.(roundTripperFunc); !ok {
   310  		t.Errorf("Expected the user client's transport to be left unmodified, got %T", userClient.Transport)
   311  	}
   312  }
   313  
   314  // TestWithCredentialsInstallsGoogleClientByDefault verifies that without a
   315  // user-supplied client the option still installs its own authorized client.
   316  func TestWithCredentialsInstallsGoogleClientByDefault(t *testing.T) {
   317  	creds := &google.Credentials{
   318  		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}),
   319  	}
   320  	for _, tc := range []struct {
   321  		name   string
   322  		client *http.Client
   323  	}{
   324  		{name: "nil client", client: nil},
   325  		{name: "http.DefaultClient", client: http.DefaultClient},
   326  	} {
   327  		t.Run(tc.name, func(t *testing.T) {
   328  			rc := requestconfig.RequestConfig{HTTPClient: tc.client}
   329  			if err := rc.Apply(WithCredentials(context.Background(), "us-east5", "proj", creds)); err != nil {
   330  				t.Fatalf("Failed to apply option: %v", err)
   331  			}
   332  			if rc.HTTPClient == nil || rc.HTTPClient == http.DefaultClient {
   333  				t.Errorf("Expected an authorized Google client to be installed, got %v", rc.HTTPClient)
   334  			}
   335  		})
   336  	}
   337  }
   338  
   339  // TestVertexClientIgnoresConfigStore is a regression test for the
   340  // first-party credential chain leaking into Vertex clients: a client
   341  // constructed with static Google credentials must never consult the shared
   342  // config store (ANTHROPIC_CONFIG_DIR), so no store-derived value — the
   343  // resolvable profile's workspace id or its access token — may reach the
   344  // wire. Authorization must stay the static OAuth token.
   345  func TestVertexClientIgnoresConfigStore(t *testing.T) {
   346  	t.Setenv("ANTHROPIC_API_KEY", "")
   347  	t.Setenv("ANTHROPIC_AUTH_TOKEN", "")
   348  	unsetEnv(t, "ANTHROPIC_PROFILE")
   349  	unsetEnv(t, "ANTHROPIC_FEDERATION_RULE_ID")
   350  	unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID")
   351  	unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN")
   352  	unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE")
   353  	unsetEnv(t, "ANTHROPIC_WORKSPACE_ID")
   354  	writeFakeConfigStore(t)
   355  
   356  	var wireHeaders http.Header
   357  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   358  		wireHeaders = r.Header.Clone()
   359  		w.Header().Set("Content-Type", "application/json")
   360  		json.NewEncoder(w).Encode(map[string]any{
   361  			"id": "msg_test", "type": "message", "role": "assistant",
   362  			"content": []map[string]any{{"type": "text", "text": "hi"}},
   363  			"model":   "claude-3-sonnet", "stop_reason": "end_turn",
   364  			"usage": map[string]any{"input_tokens": 1, "output_tokens": 1},
   365  		})
   366  	}))
   367  	t.Cleanup(server.Close)
   368  
   369  	creds := &google.Credentials{
   370  		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-vertex-access-token"}),
   371  	}
   372  	client := anthropic.NewClient(
   373  		WithCredentials(context.Background(), "us-central1", "test-project", creds),
   374  		sdkoption.WithBaseURL(server.URL),
   375  	)
   376  	_, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
   377  		Model:     "claude-3-sonnet",
   378  		MaxTokens: 1,
   379  		Messages: []anthropic.MessageParam{
   380  			anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
   381  		},
   382  	})
   383  	if err != nil {
   384  		t.Fatalf("Request failed: %v", err)
   385  	}
   386  
   387  	if got, ok := wireHeaders[http.CanonicalHeaderKey("anthropic-workspace-id")]; ok {
   388  		t.Errorf("Expected no anthropic-workspace-id header, got %q", got)
   389  	}
   390  	if got := wireHeaders.Get("X-Api-Key"); got != "" {
   391  		t.Errorf("Expected no X-Api-Key header, got %q", got)
   392  	}
   393  	if auth := wireHeaders.Get("Authorization"); auth != "Bearer fake-vertex-access-token" {
   394  		t.Errorf("Expected the static OAuth Authorization on the wire, got %q", auth)
   395  	}
   396  }
   397  
   398  // writeFakeConfigStore points ANTHROPIC_CONFIG_DIR at a temp config store
   399  // holding a resolvable default profile with a workspace id, so a client
   400  // that (incorrectly) walks the first-party credential chain would pick it
   401  // up. Values are deliberately fake.
   402  func writeFakeConfigStore(t *testing.T) {
   403  	t.Helper()
   404  	dir := t.TempDir()
   405  	if err := os.MkdirAll(filepath.Join(dir, "configs"), 0o755); err != nil {
   406  		t.Fatal(err)
   407  	}
   408  	if err := os.MkdirAll(filepath.Join(dir, "credentials"), 0o700); err != nil {
   409  		t.Fatal(err)
   410  	}
   411  	if err := os.WriteFile(filepath.Join(dir, "configs", "default.json"), []byte(`{
   412  		"authentication": {"type": "user_oauth"},
   413  		"workspace_id": "wrkspc_fake_store_value"
   414  	}`), 0o644); err != nil {
   415  		t.Fatal(err)
   416  	}
   417  	if err := os.WriteFile(filepath.Join(dir, "credentials", "default.json"),
   418  		[]byte(`{"type":"oauth_token","access_token":"fake-store-access-token"}`), 0o600); err != nil {
   419  		t.Fatal(err)
   420  	}
   421  	t.Setenv("ANTHROPIC_CONFIG_DIR", dir)
   422  }
   423  
   424  // unsetEnv unsets an env var for the duration of the test, restoring the
   425  // original value afterwards (t.Setenv registers the restore).
   426  func unsetEnv(t *testing.T, key string) {
   427  	t.Helper()
   428  	t.Setenv(key, "")
   429  	os.Unsetenv(key)
   430  }
   431  
   432  // TestVertexNonJSONBodyPassesThroughUntouched verifies that a raw, non-JSON
   433  // request body reaches the wire byte-for-byte rather than being replaced by
   434  // injected JSON fields.
   435  func TestVertexNonJSONBodyPassesThroughUntouched(t *testing.T) {
   436  	t.Setenv("ANTHROPIC_API_KEY", "")
   437  
   438  	payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x01, 0x02, 0xff}
   439  
   440  	var wireBody []byte
   441  	var wireContentType string
   442  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   443  		wireContentType = r.Header.Get("Content-Type")
   444  		var err error
   445  		if wireBody, err = io.ReadAll(r.Body); err != nil {
   446  			t.Errorf("Failed to read wire body: %v", err)
   447  		}
   448  		w.Header().Set("Content-Type", "application/json")
   449  		w.Write([]byte(`{}`))
   450  	}))
   451  	t.Cleanup(server.Close)
   452  
   453  	creds := &google.Credentials{
   454  		TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake"}),
   455  	}
   456  	client := anthropic.NewClient(
   457  		sdkoption.WithoutEnvironmentDefaults(),
   458  		WithCredentials(context.Background(), "us-central1", "test-project", creds),
   459  		sdkoption.WithBaseURL(server.URL),
   460  	)
   461  
   462  	var res map[string]any
   463  	err := client.Post(context.Background(), "/v1/projects/test-project/locations/us-central1/publishers/anthropic/models/claude-3-sonnet:rawPredict", nil, &res,
   464  		sdkoption.WithRequestBody("application/octet-stream", bytes.NewReader(payload)),
   465  	)
   466  	if err != nil {
   467  		t.Fatalf("Request failed: %v", err)
   468  	}
   469  
   470  	if !bytes.Equal(wireBody, payload) {
   471  		t.Errorf("Expected wire body %x, got %x (%q)", payload, wireBody, wireBody)
   472  	}
   473  	if wireContentType != "application/octet-stream" {
   474  		t.Errorf("Expected Content-Type %q on the wire, got %q", "application/octet-stream", wireContentType)
   475  	}
   476  }