diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 306dc078c..f16e9b1ae 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.31.0" + ".": "1.32.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5e321e8..2031df1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.32.0 (2026-04-07) + +Full Changelog: [v1.31.0...v1.32.0](https://github.com/anthropics/anthropic-sdk-go/compare/v1.31.0...v1.32.0) + +### Features + +* **bedrock:** add AnthropicBedrockMantle client ([#704](https://github.com/anthropics/anthropic-sdk-go/issues/704)) ([058e8fa](https://github.com/anthropics/anthropic-sdk-go/commit/058e8fa51bcdaf3eaa8d9c4dfb51606647eb6fae)) + ## 1.31.0 (2026-04-07) Full Changelog: [v1.30.0...v1.31.0](https://github.com/anthropics/anthropic-sdk-go/compare/v1.30.0...v1.31.0) diff --git a/README.md b/README.md index 5ab6006cd..ddd99e001 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Or explicitly add the dependency: ```sh -go get -u 'github.com/anthropics/anthropic-sdk-go@v1.31.0' +go get -u 'github.com/anthropics/anthropic-sdk-go@v1.32.0' ``` diff --git a/bedrock/bedrockmantle.go b/bedrock/bedrockmantle.go new file mode 100644 index 000000000..883f68e33 --- /dev/null +++ b/bedrock/bedrockmantle.go @@ -0,0 +1,126 @@ +package bedrock + +import ( + "context" + "fmt" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/internal/awsauth" + "github.com/anthropics/anthropic-sdk-go/option" +) + +const mantleServiceName = "bedrock-mantle" + +// MantleClientConfig holds the configuration for creating an Anthropic Bedrock Mantle client. +type MantleClientConfig struct { + // APIKey is the Anthropic API key for x-api-key authentication. + // Takes precedence over AWS credentials. When no AWS auth args are set, falls back + // to the AWS_BEARER_TOKEN_BEDROCK environment variable (then ANTHROPIC_AWS_API_KEY) + // before trying SigV4. + APIKey string + + // AWSAccessKey is the AWS access key ID for SigV4 authentication. + // Must be paired with AWSSecretAccessKey. When unset, credentials are resolved + // via the default AWS credential chain (env vars, shared credentials file, IAM roles, etc.). + AWSAccessKey string + + // AWSSecretAccessKey is the AWS secret access key for SigV4 authentication. + // When unset, credentials are resolved via the default AWS credential chain + // (env vars, shared credentials file, IAM roles, etc.). + AWSSecretAccessKey string + + // AWSSessionToken is the optional AWS session token for temporary credentials. + // When unset, resolved via the default AWS credential chain if applicable. + AWSSessionToken string + + // AWSProfile is the AWS named profile for credential resolution via the provider chain. + AWSProfile string + + // AWSRegion is the AWS region for the base URL and SigV4 signing. + // Resolved by precedence: MantleClientConfig.AWSRegion > AWS_REGION env var. + AWSRegion string + + // BaseURL overrides the default base URL. + // Resolved by precedence: MantleClientConfig.BaseURL > ANTHROPIC_BEDROCK_MANTLE_BASE_URL env > + // https://bedrock-mantle.{region}.api.aws/anthropic + BaseURL string + + // SkipAuth skips Mantle-specific authentication (API key and SigV4). + // This is useful when a gateway or proxy handles authentication on your behalf. + // Note: when using [NewMantleClient], the base SDK may still send an X-Api-Key header + // if the ANTHROPIC_API_KEY environment variable is set. + SkipAuth bool +} + +// MantleClient provides access to the Anthropic Bedrock Mantle API. +// Only the Messages API (/v1/messages) and its subpaths are supported. +type MantleClient struct { + Options []option.RequestOption + Messages anthropic.MessageService + Beta MantleBetaService +} + +// MantleBetaService exposes only the beta resources supported by Bedrock Mantle. +type MantleBetaService struct { + Options []option.RequestOption + Messages anthropic.BetaMessageService +} + +// NewMantleClient creates a new Bedrock Mantle client with the given configuration. +// Only the Messages API (/v1/messages) and its subpaths are supported on Bedrock Mantle. +// +// Any additional [option.RequestOption] values are applied after the client's +// internal options (base URL, auth, etc.), so they can be used to set custom +// headers, timeouts, middleware, and other request-level settings. +// +// Auth is resolved by precedence: +// 1. APIKey arg (x-api-key header) +// 2. AWSAccessKey + AWSSecretAccessKey args (SigV4) +// 3. AWSProfile arg (SigV4 via provider chain) +// 4. AWS_BEARER_TOKEN_BEDROCK env var, then ANTHROPIC_AWS_API_KEY (x-api-key header) +// 5. Default AWS credential chain (SigV4) +func NewMantleClient(ctx context.Context, cfg MantleClientConfig, opts ...option.RequestOption) (*MantleClient, error) { + baseOpts, err := awsauth.CreateClientOptions(ctx, mantleToInternalConfig(cfg), mantleResolveParams()) + if err != nil { + return nil, err + } + + // We intentionally do not call anthropic.DefaultClientOptions() here. + // The Mantle client resolves its own base URL, auth, and workspace ID — the + // base SDK defaults (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL) do not apply. + // + // User-provided opts are appended last so they take highest precedence. + opts = append(baseOpts, opts...) + + return &MantleClient{ + Options: opts, + Messages: anthropic.NewMessageService(opts...), + Beta: MantleBetaService{ + Options: opts, + Messages: anthropic.NewBetaMessageService(opts...), + }, + }, nil +} + +func mantleResolveParams() awsauth.ResolveParams { + return awsauth.ResolveParams{ + EnvAPIKey: "AWS_BEARER_TOKEN_BEDROCK", + EnvAPIKeyFallback: "ANTHROPIC_AWS_API_KEY", + EnvBaseURL: "ANTHROPIC_BEDROCK_MANTLE_BASE_URL", + DeriveBaseURL: func(region string) string { return fmt.Sprintf("https://bedrock-mantle.%s.api.aws/anthropic", region) }, + ServiceName: mantleServiceName, + } +} + +func mantleToInternalConfig(cfg MantleClientConfig) awsauth.ClientConfig { + return awsauth.ClientConfig{ + APIKey: cfg.APIKey, + AWSAccessKey: cfg.AWSAccessKey, + AWSSecretAccessKey: cfg.AWSSecretAccessKey, + AWSSessionToken: cfg.AWSSessionToken, + AWSProfile: cfg.AWSProfile, + AWSRegion: cfg.AWSRegion, + BaseURL: cfg.BaseURL, + SkipAuth: cfg.SkipAuth, + } +} diff --git a/bedrock/bedrockmantle_live_test.go b/bedrock/bedrockmantle_live_test.go new file mode 100644 index 000000000..749cbcc86 --- /dev/null +++ b/bedrock/bedrockmantle_live_test.go @@ -0,0 +1,141 @@ +package bedrock_test + +import ( + "context" + "os" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/bedrock" +) + +// Live integration tests for Bedrock Mantle. Skipped unless ANTHROPIC_LIVE=1. +// +// Required env vars vary by auth mode: +// +// API key mode: AWS_BEARER_TOKEN_BEDROCK (or ANTHROPIC_AWS_API_KEY), +// AWS_REGION +// +// SigV4 mode: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION +// +// Run: ANTHROPIC_LIVE=1 go test ./bedrock/... -run TestLiveMantle -v + +func skipUnlessLive(t *testing.T) { + t.Helper() + if os.Getenv("ANTHROPIC_LIVE") != "1" { + t.Skip("set ANTHROPIC_LIVE=1 to run live integration tests") + } +} + +func requireEnv(t *testing.T, names ...string) { + t.Helper() + for _, name := range names { + if os.Getenv(name) == "" { + t.Fatalf("required env var %s is not set", name) + } + } +} + +func liveModel() string { + if m := os.Getenv("ANTHROPIC_LIVE_MODEL"); m != "" { + return m + } + return "claude-sonnet-4-6" +} + +func sendMantleMessage(t *testing.T, client *bedrock.MantleClient) { + t.Helper() + + message, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: liveModel(), + MaxTokens: 32, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("Say exactly: hello")), + }, + }) + if err != nil { + t.Fatalf("Messages.New failed: %v", err) + } + if len(message.Content) == 0 { + t.Fatal("expected non-empty content in response") + } + t.Logf("response: %s", message.Content[0].Text) +} + +func TestLiveMantleAPIKey(t *testing.T) { + skipUnlessLive(t) + requireEnv(t, "AWS_REGION") + + // Need at least one of these for the API key + apiKey := os.Getenv("AWS_BEARER_TOKEN_BEDROCK") + if apiKey == "" { + apiKey = os.Getenv("ANTHROPIC_AWS_API_KEY") + } + if apiKey == "" { + t.Fatal("required env var AWS_BEARER_TOKEN_BEDROCK or ANTHROPIC_AWS_API_KEY is not set") + } + + client, err := bedrock.NewMantleClient(context.Background(), bedrock.MantleClientConfig{ + APIKey: apiKey, + }) + if err != nil { + t.Fatalf("NewMantleClient failed: %v", err) + } + + sendMantleMessage(t, client) +} + +func TestLiveMantleSigV4ExplicitCreds(t *testing.T) { + skipUnlessLive(t) + requireEnv(t, "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION") + + client, err := bedrock.NewMantleClient(context.Background(), bedrock.MantleClientConfig{ + AWSAccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + AWSSecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + AWSSessionToken: os.Getenv("AWS_SESSION_TOKEN"), + }) + if err != nil { + t.Fatalf("NewMantleClient failed: %v", err) + } + + sendMantleMessage(t, client) +} + +func TestLiveMantleSigV4DefaultChain(t *testing.T) { + skipUnlessLive(t) + requireEnv(t, "AWS_REGION") + + // Clear all API key env vars so the default AWS credential chain is used + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + + client, err := bedrock.NewMantleClient(context.Background(), bedrock.MantleClientConfig{}) + if err != nil { + t.Fatalf("NewMantleClient failed (default AWS credential chain): %v", err) + } + + sendMantleMessage(t, client) +} + +func TestLiveMantleSigV4ProfileFromCredentialsFile(t *testing.T) { + skipUnlessLive(t) + requireEnv(t, "AWS_REGION", "AWS_PROFILE") + + // Clear explicit creds and API keys so the SDK must resolve from ~/.aws/credentials + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + t.Setenv("AWS_SESSION_TOKEN", "") + + client, err := bedrock.NewMantleClient(context.Background(), bedrock.MantleClientConfig{ + AWSProfile: os.Getenv("AWS_PROFILE"), + }) + if err != nil { + t.Fatalf("NewMantleClient failed (profile from credentials file): %v", err) + } + + sendMantleMessage(t, client) +} diff --git a/bedrock/bedrockmantle_test.go b/bedrock/bedrockmantle_test.go new file mode 100644 index 000000000..f6d6c1391 --- /dev/null +++ b/bedrock/bedrockmantle_test.go @@ -0,0 +1,452 @@ +package bedrock + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/aws-sdk-go-v2/credentials" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/internal/awsauth" + "github.com/anthropics/anthropic-sdk-go/option" +) + +func makeMantleStaticConfig(region string) awssdk.Config { + return awssdk.Config{ + Region: region, + Credentials: credentials.StaticCredentialsProvider{ + Value: awssdk.Credentials{ + AccessKeyID: "test-access-key", + SecretAccessKey: "test-secret-key", + }, + }, + } +} + +type mantleCapturedRequest struct { + Headers http.Header + URL string +} + +func mantleMessagesHandler(captured *mantleCapturedRequest) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + captured.Headers = r.Header.Clone() + captured.URL = r.URL.String() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "hi"}}, + "model": "claude-sonnet-4-6-20250514", + "stop_reason": "end_turn", + "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 1, "output_tokens": 1}, + }) + } +} + +// newTestMantleClient creates a MantleClient pointed at a test server and returns +// the client and a struct that captures the request headers/URL. +func newTestMantleClient(t *testing.T, cfg MantleClientConfig, opts ...option.RequestOption) (*MantleClient, *mantleCapturedRequest) { + t.Helper() + var captured mantleCapturedRequest + server := httptest.NewServer(mantleMessagesHandler(&captured)) + t.Cleanup(server.Close) + + cfg.BaseURL = server.URL + client, err := NewMantleClient(context.Background(), cfg, opts...) + if err != nil { + t.Fatalf("NewMantleClient failed: %v", err) + } + return client, &captured +} + +func sendTestMantleRequest(t *testing.T, client *MantleClient) { + t.Helper() + _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-6-20250514", + MaxTokens: 1, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), + }, + }) + if err != nil { + t.Fatalf("request failed: %v", err) + } +} + +// --- Validation tests --- + +func TestMantleRequiresBaseURLOrRegion(t *testing.T) { + t.Setenv("AWS_REGION", "") + t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") + + _, err := NewMantleClient(context.Background(), MantleClientConfig{ + APIKey: "my-key", + }) + if err == nil { + t.Fatal("expected error when neither base URL nor region is available") + } +} + +func TestMantleRegionRequiredForSigV4(t *testing.T) { + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + _, err := NewMantleClient(context.Background(), MantleClientConfig{ + AWSAccessKey: "key", + AWSSecretAccessKey: "secret", + }) + if err == nil { + t.Fatal("expected error when region is missing and SigV4 is used") + } +} + +// --- API key mode tests --- + +func TestMantleAPIKeyModeHeaders(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "my-api-key", + AWSRegion: "us-east-1", + }) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "my-api-key" { + t.Errorf("expected x-api-key %q, got %q", "my-api-key", got) + } + if captured.Headers.Get("Authorization") != "" { + t.Error("expected no Authorization header in API key mode") + } +} + +// --- SigV4 mode tests --- + +func TestMantleSigV4ModeHeaders(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + AWSRegion: "us-east-1", + AWSAccessKey: "test-access-key", + AWSSecretAccessKey: "test-secret-key", + }) + sendTestMantleRequest(t, client) + + auth := captured.Headers.Get("Authorization") + if auth == "" { + t.Fatal("expected Authorization header in SigV4 mode") + } + if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { + t.Errorf("expected AWS4 signature, got: %s", auth) + } + if !strings.Contains(auth, mantleServiceName) { + t.Errorf("expected service name %q in Authorization, got: %s", mantleServiceName, auth) + } + if captured.Headers.Get("X-Amz-Date") == "" { + t.Error("expected X-Amz-Date header in SigV4 mode") + } +} + +// --- SigV4 middleware service name test --- + +func TestMantleSigV4ServiceName(t *testing.T) { + cfg := makeMantleStaticConfig("us-east-1") + signer := v4.NewSigner() + middleware := awsauth.SigV4Middleware(signer, cfg, mantleServiceName) + + req, err := http.NewRequest("POST", "https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages", bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + var gotAuth string + _, err = middleware(req, func(r *http.Request) (*http.Response, error) { + gotAuth = r.Header.Get("Authorization") + return &http.Response{StatusCode: 200, Body: http.NoBody}, nil + }) + if err != nil { + t.Fatalf("middleware failed: %v", err) + } + + if gotAuth == "" { + t.Fatal("expected non-empty Authorization header") + } + expectedServiceFragment := "/" + mantleServiceName + "/" + if !bytes.Contains([]byte(gotAuth), []byte(expectedServiceFragment)) { + t.Errorf("expected Authorization to contain service %q, got: %s", mantleServiceName, gotAuth) + } +} + +// --- Env var fallback tests --- + +func TestMantleAPIKeyFallbackToAWSEnv(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "aws-fallback-key") + t.Setenv("AWS_REGION", "us-east-1") + + client, captured := newTestMantleClient(t, MantleClientConfig{}) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "aws-fallback-key" { + t.Errorf("expected x-api-key %q (AWS fallback), got %q", "aws-fallback-key", got) + } +} + +func TestMantleAPIKeyMantleEnvOverridesAWSEnv(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "mantle-key") + t.Setenv("ANTHROPIC_AWS_API_KEY", "aws-key") + t.Setenv("AWS_REGION", "us-east-1") + + client, captured := newTestMantleClient(t, MantleClientConfig{}) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "mantle-key" { + t.Errorf("expected x-api-key %q (mantle-specific), got %q", "mantle-key", got) + } +} + +// --- Base URL tests --- + +func TestMantleBaseURLDerivedFromRegion(t *testing.T) { + t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{ + AWSRegion: "us-west-2", + APIKey: "my-key", + }), mantleResolveParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "https://bedrock-mantle.us-west-2.api.aws/anthropic" + if resolved.BaseURL != expected { + t.Errorf("expected base URL %q, got %q", expected, resolved.BaseURL) + } +} + +func TestMantleBaseURLFromEnv(t *testing.T) { + t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "https://custom.mantle.example.com") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "my-key") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{}), mantleResolveParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved.BaseURL != "https://custom.mantle.example.com" { + t.Errorf("expected base URL from env, got %q", resolved.BaseURL) + } +} + +func TestMantleBaseURLExplicitOverridesRegion(t *testing.T) { + t.Setenv("ANTHROPIC_BEDROCK_MANTLE_BASE_URL", "") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + resolved, err := awsauth.ResolveConfig(mantleToInternalConfig(MantleClientConfig{ + BaseURL: "https://explicit.example.com", + AWSRegion: "us-east-1", + APIKey: "my-key", + }), mantleResolveParams()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved.BaseURL != "https://explicit.example.com" { + t.Errorf("expected explicit base URL, got %q", resolved.BaseURL) + } +} + +// --- skipAuth tests --- + +func TestMantleSkipAuthNoAuthRequired(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + _, err := NewMantleClient(context.Background(), MantleClientConfig{ + BaseURL: "https://proxy.example.com", + SkipAuth: true, + }) + if err != nil { + t.Fatalf("expected no error with skipAuth, got: %v", err) + } +} + +func TestMantleSkipAuthNoAuthHeaders(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + SkipAuth: true, + }) + sendTestMantleRequest(t, client) + + if captured.Headers.Get("Authorization") != "" { + t.Error("expected no Authorization header with skipAuth") + } + if captured.Headers.Get("X-Amz-Date") != "" { + t.Error("expected no X-Amz-Date header with skipAuth") + } +} + +// --- NewMantleClient tests --- + +func TestNewMantleClientMessages(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "test-key", + AWSRegion: "us-east-1", + }) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "test-key" { + t.Errorf("expected x-api-key %q, got %q", "test-key", got) + } +} + +func TestNewMantleClientBetaMessages(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + var captured mantleCapturedRequest + server := httptest.NewServer(mantleMessagesHandler(&captured)) + t.Cleanup(server.Close) + + client, err := NewMantleClient(context.Background(), MantleClientConfig{ + APIKey: "test-key", + AWSRegion: "us-east-1", + BaseURL: server.URL, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = client.Beta.Messages.New(context.Background(), anthropic.BetaMessageNewParams{ + Model: "claude-sonnet-4-6-20250514", + MaxTokens: 1, + Messages: []anthropic.BetaMessageParam{ + anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("hi")), + }, + }) + if err != nil { + t.Fatalf("beta messages request failed: %v", err) + } +} + +// --- RequestOption tests --- + +func TestMantleClientRequestOptionsCustomHeader(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "test-key", + AWSRegion: "us-east-1", + }, option.WithHeader("X-Custom-Header", "custom-value")) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Custom-Header"); got != "custom-value" { + t.Errorf("expected X-Custom-Header %q, got %q", "custom-value", got) + } +} + +func TestMantleClientRequestOptionsOverrideInternal(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + // User-provided opts should override internal opts (e.g. override the API key header) + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "internal-key", + AWSRegion: "us-east-1", + }, option.WithAPIKey("override-key")) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "override-key" { + t.Errorf("expected X-Api-Key %q (from RequestOption override), got %q", "override-key", got) + } +} + +func TestMantleClientPerRequestOptionsOverrideClientOptions(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "test-key", + AWSRegion: "us-east-1", + }, option.WithHeader("X-Custom-Header", "client-level")) + + // Send request with per-request option that overrides the client-level header + _, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-6-20250514", + MaxTokens: 1, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), + }, + }, option.WithHeader("X-Custom-Header", "request-level")) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + if got := captured.Headers.Get("X-Custom-Header"); got != "request-level" { + t.Errorf("expected X-Custom-Header %q (per-request override), got %q", "request-level", got) + } +} + +func TestMantleClientRequestOptionsMiddleware(t *testing.T) { + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + middlewareCalled := false + mw := func(r *http.Request, next option.MiddlewareNext) (*http.Response, error) { + middlewareCalled = true + r.Header.Set("X-From-Middleware", "true") + return next(r) + } + + client, captured := newTestMantleClient(t, MantleClientConfig{ + APIKey: "test-key", + AWSRegion: "us-east-1", + }, option.WithMiddleware(mw)) + sendTestMantleRequest(t, client) + + if !middlewareCalled { + t.Error("expected middleware to be called") + } + if got := captured.Headers.Get("X-From-Middleware"); got != "true" { + t.Errorf("expected X-From-Middleware %q, got %q", "true", got) + } +} + +// --- ANTHROPIC_API_KEY isolation test --- + +func TestMantleDoesNotLeakAnthropicAPIKey(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "should-not-appear") + t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "") + t.Setenv("ANTHROPIC_AWS_API_KEY", "") + + client, captured := newTestMantleClient(t, MantleClientConfig{ + AWSRegion: "us-east-1", + AWSAccessKey: "test-access-key", + AWSSecretAccessKey: "test-secret-key", + }) + sendTestMantleRequest(t, client) + + if got := captured.Headers.Get("X-Api-Key"); got != "" { + t.Errorf("expected no x-api-key header when using SigV4, got %q", got) + } +} diff --git a/internal/awsauth/awsauth.go b/internal/awsauth/awsauth.go index dd324aebf..118906d39 100644 --- a/internal/awsauth/awsauth.go +++ b/internal/awsauth/awsauth.go @@ -95,7 +95,7 @@ func ResolveConfig(cfg ClientConfig, params ResolveParams) (ResolvedConfig, erro rc.SkipAuth = cfg.SkipAuth - // Workspace ID: arg > primary env > fallback env, required unless skipAuth + // Workspace ID: arg > primary env > fallback env rc.WorkspaceID = cfg.WorkspaceID if rc.WorkspaceID == "" && params.EnvWorkspaceID != "" { rc.WorkspaceID = os.Getenv(params.EnvWorkspaceID) @@ -103,7 +103,8 @@ func ResolveConfig(cfg ClientConfig, params ResolveParams) (ResolvedConfig, erro if rc.WorkspaceID == "" && params.EnvWorkspaceIDFallback != "" { rc.WorkspaceID = os.Getenv(params.EnvWorkspaceIDFallback) } - if rc.WorkspaceID == "" && !rc.SkipAuth { + // Workspace ID is required when env var names are configured (i.e. the caller expects it) + if rc.WorkspaceID == "" && !rc.SkipAuth && (params.EnvWorkspaceID != "" || cfg.WorkspaceID != "") { envHint := params.EnvWorkspaceID if envHint == "" { envHint = "ANTHROPIC_AWS_WORKSPACE_ID" diff --git a/internal/version.go b/internal/version.go index b7047d585..d10cb0080 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "1.31.0" // x-release-please-version +const PackageVersion = "1.32.0" // x-release-please-version