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 }