diff --git a/docs/_getting_started/configuration.md b/docs/_getting_started/configuration.md index e34b30ad6..0d750e221 100644 --- a/docs/_getting_started/configuration.md +++ b/docs/_getting_started/configuration.md @@ -65,7 +65,7 @@ RubyLLM.configure do |config| config.gpustack_api_base = ENV['GPUSTACK_API_BASE'] config.gpustack_api_key = ENV['GPUSTACK_API_KEY'] - # AWS Bedrock (uses standard AWS credential chain if not set) + # AWS Bedrock - Static credentials config.bedrock_api_key = ENV['AWS_ACCESS_KEY_ID'] config.bedrock_secret_key = ENV['AWS_SECRET_ACCESS_KEY'] config.bedrock_region = ENV['AWS_REGION'] # Required for Bedrock @@ -90,6 +90,37 @@ end These headers are optional and only needed for organization-specific billing or project tracking. +### AWS Bedrock Credential Provider + +For dynamic credential management (IAM roles, assume role, credential +rotation), use an AWS SDK credential provider instead of static keys: + +```ruby +require 'aws-sdk-core' + +RubyLLM.configure do |config| + config.bedrock_region = 'us-east-1' # Required + + # Use EC2 instance profile or ECS task role + config.bedrock_credential_provider = Aws::InstanceProfileCredentials.new + + # Or assume a role + config.bedrock_credential_provider = Aws::AssumeRoleCredentials.new( + role_arn: 'arn:aws:iam::123456789012:role/MyBedrockRole', + role_session_name: 'ruby-llm-session' + ) + + # Or use shared credentials from ~/.aws/credentials + config.bedrock_credential_provider = Aws::SharedCredentials.new( + profile_name: 'my-profile' + ) +end +``` + +When `bedrock_credential_provider` is set, it takes precedence over static +credentials (`bedrock_api_key`, `bedrock_secret_key`, `bedrock_session_token`). +The provider handles credential refresh automatically. + ## Custom Endpoints ### OpenAI-Compatible APIs @@ -335,6 +366,7 @@ RubyLLM.configure do |config| config.bedrock_secret_key = String config.bedrock_region = String config.bedrock_session_token = String + config.bedrock_credential_provider = Object # Aws::CredentialProvider # Default Models config.default_model = String @@ -363,4 +395,4 @@ Now that you've configured RubyLLM, you're ready to: - [Start chatting with AI models]({% link _core_features/chat.md %}) - [Work with different providers and models]({% link _advanced/models.md %}) -- [Set up Rails integration]({% link _advanced/rails.md %}) \ No newline at end of file +- [Set up Rails integration]({% link _advanced/rails.md %}) diff --git a/lib/ruby_llm/configuration.rb b/lib/ruby_llm/configuration.rb index eda2c3354..0d33435ae 100644 --- a/lib/ruby_llm/configuration.rb +++ b/lib/ruby_llm/configuration.rb @@ -18,6 +18,7 @@ class Configuration :bedrock_secret_key, :bedrock_region, :bedrock_session_token, + :bedrock_credential_provider, :openrouter_api_key, :ollama_api_base, :gpustack_api_base, diff --git a/lib/ruby_llm/providers/bedrock.rb b/lib/ruby_llm/providers/bedrock.rb index 50474b27f..66cc40cc8 100644 --- a/lib/ruby_llm/providers/bedrock.rb +++ b/lib/ruby_llm/providers/bedrock.rb @@ -41,13 +41,30 @@ def sign_request(url, method: :post, payload: nil) end def create_signer - Signing::Signer.new({ - access_key_id: @config.bedrock_api_key, - secret_access_key: @config.bedrock_secret_key, - session_token: @config.bedrock_session_token, - region: @config.bedrock_region, - service: 'bedrock' - }) + signer_options = { + region: @config.bedrock_region, + service: 'bedrock' + } + + if @config.bedrock_credential_provider + validate_credential_provider!(@config.bedrock_credential_provider) + signer_options[:credentials_provider] = @config.bedrock_credential_provider + else + signer_options.merge!({ + access_key_id: @config.bedrock_api_key, + secret_access_key: @config.bedrock_secret_key, + session_token: @config.bedrock_session_token + }) + end + + Signing::Signer.new(signer_options) + end + + def validate_credential_provider!(provider) + return if provider.respond_to?(:credentials) + + raise ConfigurationError, + 'bedrock_credential_provider must respond to :credentials method' end def build_request(url, method: :post, payload: nil) @@ -68,13 +85,23 @@ def build_headers(signature_headers, streaming: false) ) end + def configuration_requirements + # If credential provider is configured, only region is required + # Otherwise, require static credentials (api_key, secret_key) + region + if @config.bedrock_credential_provider + %i[bedrock_region] + else + %i[bedrock_api_key bedrock_secret_key bedrock_region] + end + end + class << self def capabilities Bedrock::Capabilities end def configuration_requirements - %i[bedrock_api_key bedrock_secret_key bedrock_region] + %i[bedrock_region] end end end diff --git a/spec/ruby_llm/providers/bedrock/models_spec.rb b/spec/ruby_llm/providers/bedrock/models_spec.rb index ffc7478f9..05947e9d4 100644 --- a/spec/ruby_llm/providers/bedrock/models_spec.rb +++ b/spec/ruby_llm/providers/bedrock/models_spec.rb @@ -38,7 +38,11 @@ it 'adds us. prefix to model ID' do # Mock a provider instance to test the region functionality - allow(RubyLLM.config).to receive(:bedrock_region).and_return('us-east-1') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'us-east-1', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) @@ -63,7 +67,11 @@ it 'does not add us. prefix to model ID' do # Mock a provider instance to test the region functionality - allow(RubyLLM.config).to receive(:bedrock_region).and_return('us-east-1') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'us-east-1', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) @@ -88,7 +96,11 @@ it 'does not add us. prefix to model ID' do # Mock a provider instance to test the region functionality - allow(RubyLLM.config).to receive(:bedrock_region).and_return('us-east-1') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'us-east-1', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) @@ -112,7 +124,11 @@ it 'does not add us. prefix to model ID' do # Mock a provider instance to test the region functionality - allow(RubyLLM.config).to receive(:bedrock_region).and_return('us-east-1') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'us-east-1', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) @@ -125,7 +141,11 @@ # New specs for region-aware inference profile handling describe '#model_id_with_region with region awareness' do let(:provider_instance) do - allow(RubyLLM.config).to receive(:bedrock_region).and_return('eu-west-3') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'eu-west-3', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) provider @@ -163,7 +183,11 @@ context 'with AP region configured' do let(:provider_instance) do - allow(RubyLLM.config).to receive(:bedrock_region).and_return('ap-south-1') + allow(RubyLLM.config).to receive_messages( + bedrock_region: 'ap-south-1', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) provider @@ -184,7 +208,11 @@ context 'with region prefix edge cases' do it 'handles empty region gracefully' do - allow(RubyLLM.config).to receive(:bedrock_region).and_return('') + allow(RubyLLM.config).to receive_messages( + bedrock_region: '', + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) @@ -208,7 +236,11 @@ } regions_and_expected_prefixes.each do |region, expected_prefix| - allow(RubyLLM.config).to receive(:bedrock_region).and_return(region) + allow(RubyLLM.config).to receive_messages( + bedrock_region: region, + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) provider = RubyLLM::Providers::Bedrock.new(RubyLLM.config) provider.extend(described_class) diff --git a/spec/ruby_llm/providers/bedrock_spec.rb b/spec/ruby_llm/providers/bedrock_spec.rb new file mode 100644 index 000000000..cd2800668 --- /dev/null +++ b/spec/ruby_llm/providers/bedrock_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::Bedrock do + include AWSCredentialsHelpers + + let(:config) { instance_double(RubyLLM::Configuration) } + let(:provider) { described_class.new(config) } + + before do + # Default config values needed for provider initialization + allow(config).to receive_messages( + bedrock_region: 'us-east-1', + bedrock_api_key: nil, + bedrock_secret_key: nil, + bedrock_session_token: nil, + bedrock_credential_provider: nil, + request_timeout: 120, + max_retries: 3, + retry_interval: 0.1, + retry_backoff_factor: 2, + retry_interval_randomness: 0.5, + http_proxy: nil, + logger: nil + ) + end + + describe '#create_signer' do + context 'with static credentials' do + before do + allow(config).to receive_messages( + bedrock_api_key: 'test-key-id', + bedrock_secret_key: 'test-secret-key', + bedrock_session_token: 'test-session-token' + ) + end + + it 'creates signer with static credentials' do + signer = provider.send(:create_signer) + + expect(signer).to be_a(RubyLLM::Providers::Bedrock::Signing::Signer) + expect(signer.region).to eq('us-east-1') + expect(signer.service).to eq('bedrock') + # Verify it was created with a StaticCredentialsProvider wrapping the static creds + expect(signer.credentials_provider).to be_a(RubyLLM::Providers::Bedrock::Signing::StaticCredentialsProvider) + end + end + + context 'with credentials provider' do + let(:mock_credentials) do + AWSCredentialsHelpers::MockCredentials.new( + access_key_id: 'provider-key-id', + secret_access_key: 'provider-secret-key', + session_token: 'provider-session-token' + ) + end + let(:credentials_provider) { AWSCredentialsHelpers::MockCredentialProvider.new(mock_credentials) } + + before do + allow(config).to receive(:bedrock_credential_provider).and_return(credentials_provider) + end + + it 'creates signer with credential provider' do + signer = provider.send(:create_signer) + + expect(signer).to be_a(RubyLLM::Providers::Bedrock::Signing::Signer) + expect(signer.region).to eq('us-east-1') + expect(signer.service).to eq('bedrock') + expect(signer.credentials_provider).to eq(credentials_provider) + end + + it 'does not set static credentials when provider is configured' do + allow(config).to receive_messages( + bedrock_api_key: 'static-key', + bedrock_secret_key: 'static-secret', + bedrock_session_token: 'static-token' + ) + + signer = provider.send(:create_signer) + + # Verify credentials_provider is set and is the one we provided + expect(signer.credentials_provider).to eq(credentials_provider) + end + + it 'validates that provider responds to :credentials' do + allow(provider).to receive(:validate_credential_provider!) + + provider.send(:create_signer) + + expect(provider).to have_received(:validate_credential_provider!).with(credentials_provider) + end + end + + context 'with precedence' do + let(:mock_credentials) do + AWSCredentialsHelpers::MockCredentials.new( + access_key_id: 'provider-key', + secret_access_key: 'provider-secret', + session_token: 'provider-token' + ) + end + let(:credentials_provider) { AWSCredentialsHelpers::MockCredentialProvider.new(mock_credentials) } + + before do + allow(config).to receive_messages( + bedrock_credential_provider: credentials_provider, + bedrock_api_key: 'static-key', + bedrock_secret_key: 'static-secret', + bedrock_session_token: 'static-token' + ) + end + + it 'uses credential_provider over static keys when both are present' do + signer = provider.send(:create_signer) + + expect(signer).to be_a(RubyLLM::Providers::Bedrock::Signing::Signer) + expect(signer.credentials_provider).to eq(credentials_provider) + end + end + end + + describe '#validate_credential_provider!' do + # These tests need a valid provider to initialize, then we test validation with different providers + let(:mock_credentials) { AWSCredentialsHelpers::MockCredentials.new(access_key_id: 'key', secret_access_key: 'secret') } + let(:credentials_provider) { AWSCredentialsHelpers::MockCredentialProvider.new(mock_credentials) } + + before do + allow(config).to receive(:bedrock_credential_provider).and_return(credentials_provider) + end + + context 'with valid provider' do + let(:valid_provider) { AWSCredentialsHelpers::MockCredentialProvider.new(mock_credentials) } + + it 'does not raise error when provider responds to :credentials' do + expect do + provider.send(:validate_credential_provider!, valid_provider) + end.not_to raise_error + end + end + + context 'with invalid provider' do + let(:invalid_provider) { AWSCredentialsHelpers::InvalidProvider.new } + + it 'raises ConfigurationError when provider does not respond to :credentials' do + expect do + provider.send(:validate_credential_provider!, invalid_provider) + end.to raise_error(RubyLLM::ConfigurationError, + 'bedrock_credential_provider must respond to :credentials method') + end + end + end + + describe '#configuration_requirements' do + context 'with credential provider' do + let(:mock_credentials) { AWSCredentialsHelpers::MockCredentials.new(access_key_id: 'key', secret_access_key: 'secret') } + let(:credentials_provider) { AWSCredentialsHelpers::MockCredentialProvider.new(mock_credentials) } + + before do + allow(config).to receive(:bedrock_credential_provider).and_return(credentials_provider) + end + + it 'only requires bedrock_region' do + expect(provider.send(:configuration_requirements)).to eq(%i[bedrock_region]) + end + end + + context 'without credential provider' do + before do + allow(config).to receive_messages( + bedrock_api_key: 'test-key', + bedrock_secret_key: 'test-secret' + ) + end + + it 'requires static credentials and region' do + expect(provider.send(:configuration_requirements)).to eq(%i[bedrock_api_key bedrock_secret_key bedrock_region]) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0b60aa315..98220692d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,3 +17,4 @@ require_relative 'support/vcr_configuration' require_relative 'support/models_to_test' require_relative 'support/streaming_error_helpers' +require_relative 'support/aws_credentials_helpers' diff --git a/spec/support/aws_credentials_helpers.rb b/spec/support/aws_credentials_helpers.rb new file mode 100644 index 000000000..fe04f4f38 --- /dev/null +++ b/spec/support/aws_credentials_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Mock AWS SDK credential classes for testing Bedrock credential provider support +# without coupling tests to the actual aws-sdk-core gem + +module AWSCredentialsHelpers + # Mock credentials object that mimics Aws::Credentials structure + class MockCredentials + attr_reader :access_key_id, :secret_access_key, :session_token + + def initialize(access_key_id:, secret_access_key:, session_token: nil) + @access_key_id = access_key_id + @secret_access_key = secret_access_key + @session_token = session_token + end + end + + # Mock credentials provider that mimics AWS SDK credential provider interface + class MockCredentialProvider + attr_reader :credentials + + def initialize(credentials) + @credentials = credentials + end + end + + # Invalid provider for testing validation (does not respond to :credentials) + class InvalidProvider # rubocop:disable Lint/EmptyClass + # Intentionally empty - used to test validation of provider interface + end +end