Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions activeagent.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "jbuilder", "~> 2.14"

spec.add_development_dependency "anthropic", "~> 1.12"
spec.add_development_dependency "aws-sdk-bedrockruntime"
spec.add_development_dependency "openai", "~> 0.34"

spec.add_development_dependency "capybara", "~> 3.40"
Expand Down
8 changes: 8 additions & 0 deletions lib/active_agent/providers/bedrock/_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

require_relative "options"
require_relative "bearer_client"
require_relative "../anthropic/_types"

# Bedrock uses the same request/response types as Anthropic.
# The BedrockClient handles all protocol translation internally.
109 changes: 109 additions & 0 deletions lib/active_agent/providers/bedrock/bearer_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

module ActiveAgent
module Providers
module Bedrock
# Client for AWS Bedrock using bearer token (API key) authentication.
#
# Subclasses Anthropic::Client directly to reuse its built-in bearer
# token support via the +auth_token+ parameter, while adding Bedrock-
# specific request transformations (URL path rewriting, anthropic_version
# injection) copied from Anthropic::BedrockClient.
#
# This avoids Anthropic::BedrockClient which requires SigV4 credentials
# and would fail when only a bearer token is available.
#
# @see https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html
class BearerClient < ::Anthropic::Client
BEDROCK_VERSION = "bedrock-2023-05-31"

# @return [String]
attr_reader :aws_region

# @param aws_region [String] AWS region for the Bedrock endpoint
# @param bearer_token [String] AWS Bedrock API key (bearer token)
# @param base_url [String, nil] Override the default Bedrock endpoint
# @param max_retries [Integer]
# @param timeout [Float]
# @param initial_retry_delay [Float]
# @param max_retry_delay [Float]
def initialize(
aws_region:,
bearer_token:,
base_url: nil,
max_retries: self.class::DEFAULT_MAX_RETRIES,
timeout: self.class::DEFAULT_TIMEOUT_IN_SECONDS,
initial_retry_delay: self.class::DEFAULT_INITIAL_RETRY_DELAY,
max_retry_delay: self.class::DEFAULT_MAX_RETRY_DELAY
)
@aws_region = aws_region

base_url ||= "https://bedrock-runtime.#{aws_region}.amazonaws.com"

super(
auth_token: bearer_token,
api_key: nil,
base_url: base_url,
max_retries: max_retries,
timeout: timeout,
initial_retry_delay: initial_retry_delay,
max_retry_delay: max_retry_delay
)

@messages = ::Anthropic::Resources::Messages.new(client: self)
@completions = ::Anthropic::Resources::Completions.new(client: self)
@beta = ::Anthropic::Resources::Beta.new(client: self)
end

private

# Intercepts request building to apply Bedrock-specific transformations
# before the parent class processes the request.
def build_request(req, opts)
fit_req_to_bedrock_specs!(req)
req = super
body = req.fetch(:body)
req[:body] = StringIO.new(body.to_a.join) if body.is_a?(Enumerator)
req
Comment on lines +62 to +67
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringIO is referenced here but the file doesn’t require "stringio". In environments where StringIO isn’t preloaded, this will raise NameError; add the stdlib require near the top of the file.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same situation as the uri comment — the upstream anthropic gem already requires stringio at the top level in lib/anthropic.rb, so it's always available when BearerClient code executes. No additional require needed here.

end

# Rewrites Anthropic API paths to Bedrock endpoint paths and injects
# the Bedrock anthropic_version field.
#
# Adapted from Anthropic::Helpers::Bedrock::Client#fit_req_to_bedrock_specs!
def fit_req_to_bedrock_specs!(request_components)
if (body = request_components[:body]).is_a?(Hash)
body[:anthropic_version] ||= BEDROCK_VERSION
body.transform_keys!("anthropic-beta": :anthropic_beta)
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash#transform_keys! doesn’t accept a key-mapping hash in Ruby/ActiveSupport; this call will raise (or no-op depending on version) and won’t rewrite the key as intended. Replace it with an explicit key rename (e.g., delete the old key and assign the new one) or use the block form of transform_keys!.

Suggested change
body.transform_keys!("anthropic-beta": :anthropic_beta)
if body.key?("anthropic-beta")
body[:anthropic_beta] = body.delete("anthropic-beta")
elsif body.key?(:'anthropic-beta')
body[:anthropic_beta] = body.delete(:'anthropic-beta')
end

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate the review! This one is actually valid Ruby — Hash#transform_keys! has supported a key-mapping hash argument since Ruby 2.7 (we're on 3.4.7). The upstream anthropic gem uses the exact same pattern on line 240 of lib/anthropic/helpers/bedrock/client.rb:

body.transform_keys!("anthropic-beta": :anthropic_beta)

Our BearerClient intentionally mirrors that upstream implementation, so no change needed here.

end

case request_components[:path]
in %r{^v1/messages/batches}
raise NotImplementedError, "The Batch API is not supported in Bedrock yet"
in %r{v1/messages/count_tokens}
raise NotImplementedError, "Token counting is not supported in Bedrock yet"
in %r{v1/models\?beta=true}
raise NotImplementedError,
"Please instead use https://docs.anthropic.com/en/api/claude-on-amazon-bedrock#list-available-models " \
"to list available models on Bedrock."
else
end

if %w[
v1/complete
v1/messages
v1/messages?beta=true
].include?(request_components[:path]) && request_components[:method] == :post && body.is_a?(Hash)
model = body.delete(:model)
model = URI.encode_www_form_component(model.to_s)
stream = body.delete(:stream) || false
Comment on lines +96 to +99
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URI.encode_www_form_component is used here but the file doesn’t require "uri". Add the stdlib require near the top so this doesn’t raise NameError when URI isn’t already loaded.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. In this case the upstream anthropic gem (v1.23.0) already requires uri at the top level in lib/anthropic.rb, so it's guaranteed to be loaded by the time our code runs. Since BearerClient inherits from Anthropic::Client, adding an explicit require here would be redundant. Happy to revisit if the upstream dependency changes though.

request_components[:path] =
stream ? "model/#{model}/invoke-with-response-stream" : "model/#{model}/invoke"
end

request_components
end
end
end
end
end
77 changes: 77 additions & 0 deletions lib/active_agent/providers/bedrock/options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

require "active_agent/providers/common/model"

module ActiveAgent
module Providers
module Bedrock
# Configuration for AWS Bedrock provider.
#
# AWS credentials are resolved in order:
# 1. Explicit options (aws_access_key, aws_secret_key)
# 2. Environment variables (AWS_REGION, AWS_ACCESS_KEY_ID, etc.)
# 3. AWS SDK default chain (profiles, IAM roles, instance metadata)
#
# Unlike the Anthropic provider, no API key is needed — authentication
# is handled entirely through AWS credentials.
#
# @example Minimal config (uses SDK default chain)
# Bedrock::Options.new(aws_region: "eu-west-2")
#
# @example Explicit credentials
# Bedrock::Options.new(
# aws_region: "eu-west-2",
# aws_access_key: "AKIA...",
# aws_secret_key: "..."
# )
#
# @example With profile
# Bedrock::Options.new(
# aws_region: "eu-west-2",
# aws_profile: "my-profile"
# )
class Options < Common::BaseModel
attribute :aws_region, :string
attribute :aws_access_key, :string
attribute :aws_secret_key, :string
attribute :aws_session_token, :string
attribute :aws_profile, :string
attribute :aws_bearer_token, :string
attribute :base_url, :string
attribute :anthropic_beta, :string

attribute :max_retries, :integer, default: ::Anthropic::Client::DEFAULT_MAX_RETRIES
attribute :timeout, :float, default: ::Anthropic::Client::DEFAULT_TIMEOUT_IN_SECONDS
attribute :initial_retry_delay, :float, default: ::Anthropic::Client::DEFAULT_INITIAL_RETRY_DELAY
attribute :max_retry_delay, :float, default: ::Anthropic::Client::DEFAULT_MAX_RETRY_DELAY

def initialize(kwargs = {})
kwargs = kwargs.deep_symbolize_keys if kwargs.respond_to?(:deep_symbolize_keys)

super(**deep_compact(kwargs.except(:default_url_options).merge(
aws_region: kwargs[:aws_region] || ENV["AWS_REGION"] || ENV["AWS_DEFAULT_REGION"],
aws_access_key: kwargs[:aws_access_key] || ENV["AWS_ACCESS_KEY_ID"],
aws_secret_key: kwargs[:aws_secret_key] || ENV["AWS_SECRET_ACCESS_KEY"],
aws_session_token: kwargs[:aws_session_token] || ENV["AWS_SESSION_TOKEN"],
aws_profile: kwargs[:aws_profile] || ENV["AWS_PROFILE"],
aws_bearer_token: kwargs[:aws_bearer_token] || ENV["AWS_BEARER_TOKEN_BEDROCK"]
)))
end

# Bedrock handles authentication at the client level (SigV4 or bearer token),
# so no extra headers are needed in request options.
def extra_headers
{}
end

# Excludes sensitive AWS credentials from serialized output.
# The provider's client() method reads credentials directly from options attributes.
def serialize
attributes.symbolize_keys.except(
:aws_access_key, :aws_secret_key, :aws_session_token, :aws_profile, :aws_bearer_token
)
end
end
end
end
end
84 changes: 84 additions & 0 deletions lib/active_agent/providers/bedrock_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require_relative "anthropic_provider"
require_relative "bedrock/_types"

module ActiveAgent
module Providers
# Provider for Anthropic models hosted on AWS Bedrock.
#
# Inherits all functionality from AnthropicProvider (streaming, tool use,
# multimodal, JSON format emulation) and overrides only the client
# construction to use Anthropic::BedrockClient for AWS authentication.
#
# @example Configuration in active_agent.yml
# bedrock:
# service: "Bedrock"
# aws_region: "eu-west-2"
# model: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
#
# @example Agent usage
# class SummaryAgent < ApplicationAgent
# generate_with :bedrock, model: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
#
# def summarize
# prompt(message: params[:message])
# end
# end
#
# @see AnthropicProvider
class BedrockProvider < AnthropicProvider
# @return [String]
def self.service_name
"Bedrock"
end

# @return [Class]
def self.options_klass
Bedrock::Options
end

# @return [ActiveModel::Type::Value]
def self.prompt_request_type
Anthropic::RequestType.new
end

# Returns a configured Bedrock client.
#
# When a bearer token is available (via +aws_bearer_token+ option or
# +AWS_BEARER_TOKEN_BEDROCK+ env var), uses {Bedrock::BearerClient}
# which sends an +Authorization: Bearer+ header.
#
# Otherwise, falls back to {Anthropic::BedrockClient} which handles
# SigV4 signing, credential resolution, and Bedrock URL path rewriting.
#
# @return [Bedrock::BearerClient, Anthropic::Helpers::Bedrock::Client]
def client
@client ||= if options.aws_bearer_token.present?
Bedrock::BearerClient.new(
aws_region: options.aws_region,
bearer_token: options.aws_bearer_token,
base_url: options.base_url.presence,
max_retries: options.max_retries,
timeout: options.timeout,
initial_retry_delay: options.initial_retry_delay,
max_retry_delay: options.max_retry_delay
)
else
::Anthropic::BedrockClient.new(
aws_region: options.aws_region,
aws_access_key: options.aws_access_key,
aws_secret_key: options.aws_secret_key,
aws_session_token: options.aws_session_token,
aws_profile: options.aws_profile,
base_url: options.base_url.presence,
max_retries: options.max_retries,
timeout: options.timeout,
initial_retry_delay: options.initial_retry_delay,
max_retry_delay: options.max_retry_delay
)
end
end
end
end
end
22 changes: 22 additions & 0 deletions test/dummy/app/agents/providers/bedrock_agent.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Providers
# Example agent using Anthropic models via AWS Bedrock.
#
# Demonstrates basic prompt generation with the Bedrock provider.
# Configured to use Claude Sonnet via cross-region inference.
#
# @example Basic usage
# response = Providers::BedrockAgent.ask(message: "Hello").generate_now
# response.message.content #=> "Hi! How can I help you today?"
# region agent
class BedrockAgent < ApplicationAgent
generate_with :bedrock, model: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"

# @return [ActiveAgent::Generation]
def ask
prompt(message: params[:message])
end
end
# endregion agent
end
11 changes: 11 additions & 0 deletions test/dummy/config/active_agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ ollama: &ollama
service: "Ollama"
model: "gpt-oss:20b"
# endregion ollama_anchor
# region bedrock_anchor
bedrock: &bedrock
service: "Bedrock"
aws_region: <%= ENV.fetch("AWS_REGION", "us-east-1") %>
# endregion bedrock_anchor
# region mock_anchor
mock: &mock
service: "Mock"
Expand Down Expand Up @@ -51,6 +56,10 @@ development:
anthropic:
<<: *anthropic
# endregion anthropic_dev_config
# region bedrock_dev_config
bedrock:
<<: *bedrock
# endregion bedrock_dev_config
# region mock_dev_config
mock:
<<: *mock
Expand All @@ -69,6 +78,8 @@ test:
<<: *ollama
anthropic:
<<: *anthropic
bedrock:
<<: *bedrock
mock:
<<: *mock
# endregion config_test
Loading