-
-
Notifications
You must be signed in to change notification settings - Fork 79
Add AWS Bedrock provider #316
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d5cc97d
8b32960
ca9b4ea
d1e145e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 | ||||||||||||||||
| 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) | ||||||||||||||||
|
||||||||||||||||
| 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 |
There was a problem hiding this comment.
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.
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
| 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 |
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StringIOis referenced here but the file doesn’trequire "stringio". In environments where StringIO isn’t preloaded, this will raiseNameError; add the stdlib require near the top of the file.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same situation as the
uricomment — the upstreamanthropicgem already requiresstringioat the top level inlib/anthropic.rb, so it's always available whenBearerClientcode executes. No additional require needed here.