diff --git a/Gemfile b/Gemfile index 1be178cb4..735cab4a8 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,7 @@ end group :development, :test do gem "async" + gem "aws-sdk-core", "~> 3" gem "minitest" gem "minitest-focus" gem "minitest-hooks" diff --git a/Gemfile.lock b/Gemfile.lock index 1c4c8802e..bb1e0ae72 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -41,6 +41,18 @@ GEM io-event (~> 1.11) metrics (~> 0.12) traces (~> 0.18) + aws-eventstream (1.4.0) + aws-partitions (1.1261.0) + aws-sdk-core (3.252.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) benchmark (0.5.0) bigdecimal (3.3.1) @@ -72,6 +84,7 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) io-event (1.11.2) + jmespath (1.6.2) json (2.15.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) @@ -206,6 +219,7 @@ PLATFORMS DEPENDENCIES async + aws-sdk-core (~> 3) minitest minitest-focus minitest-hooks diff --git a/README.md b/README.md index 64fb7775b..ee880dae6 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,56 @@ puts(edited.data.first) Note that you can also pass a raw `IO` descriptor, but this disables retries, as the library can't be sure if the descriptor is a file or pipe (which cannot be rewound). +## Amazon Bedrock + +Use the standard client with the Bedrock provider to call OpenAI models through Amazon Bedrock's OpenAI-compatible API. Add `aws-sdk-core` to your application for AWS credential discovery and SigV4 signing: + +```ruby +gem "openai" +gem "aws-sdk-core", "~> 3" +``` + +With your normal AWS credentials configured, only the region is required: + +```ruby +require "openai" + +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-west-2") +) + +response = client.responses.create( + model: ENV.fetch("BEDROCK_MODEL"), + input: "Say hello!" +) + +puts(response.output_text) +``` + +The provider uses the standard AWS credential chain, including environment credentials, `~/.aws/credentials`, `~/.aws/config`, `AWS_PROFILE`, named profiles, SSO and assume-role profiles, and workload credentials. Select a profile explicitly with: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + profile: "engineering" + ) +) +``` + +`AWS_BEARER_TOKEN_BEDROCK` and explicit Bedrock bearer credentials are also supported. Bearer authentication does not load or require `aws-sdk-core`: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + api_key: ENV.fetch("AWS_BEARER_TOKEN_BEDROCK") + ) +) +``` + +See [bedrock.md](bedrock.md) for authentication precedence, static and refreshable credentials, endpoint configuration, and SigV4 request constraints. + ## Workload Identity Authentication For secure, automated environments like cloud-managed Kubernetes, Azure, and GCP, you can use workload identity authentication with short-lived tokens from cloud identity providers instead of long-lived API keys. diff --git a/bedrock.md b/bedrock.md new file mode 100644 index 000000000..3448ced27 --- /dev/null +++ b/bedrock.md @@ -0,0 +1,132 @@ +# Amazon Bedrock + +Configure the standard `OpenAI::Client` with the Bedrock provider to use [Amazon Bedrock's OpenAI-compatible API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html): + +```ruby +require "openai" + +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-west-2") +) + +response = client.responses.create( + model: ENV.fetch("BEDROCK_MODEL"), + input: "Say hello!" +) + +puts(response.output_text) +``` + +The provider uses `https://bedrock-mantle..api.aws/v1` by default and exposes the normal SDK resources. AWS controls which endpoints and features are supported; unsupported calls surface as normal API errors. + +The region is resolved from the explicit `region` option, `AWS_REGION`, `AWS_DEFAULT_REGION`, or the selected AWS profile. Override the endpoint with `base_url` or `AWS_BEDROCK_BASE_URL`: + +```ruby +provider = OpenAI::Providers.bedrock( + region: "us-west-2", + base_url: "https://bedrock.example.com/v1" +) +``` + +## Authentication + +The provider selects authentication in this order: + +1. One explicit mode passed to `bedrock(...)`: `api_key`, `token_provider`, static AWS credentials, `profile`, or `credentials_provider`. +2. The Bedrock bearer credential in `AWS_BEARER_TOKEN_BEDROCK`. +3. The standard AWS credential chain. + +Explicit bearer and AWS credential modes are mutually exclusive. Configure only one explicit AWS mode at a time. + +### AWS credentials and SigV4 + +AWS authentication is an optional integration so applications that do not use Bedrock do not install or load AWS packages. Add the AWS SDK core gem to your application: + +```ruby +gem "aws-sdk-core", "~> 3" +``` + +Then run `bundle install`. If you are not using Bundler, run `gem install aws-sdk-core`. + +Omit explicit authentication to use environment credentials, the shared credentials and config files, SSO or assume-role profiles, web identity, ECS credentials, or EC2 instance credentials: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-west-2") +) +``` + +Select a named profile with: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + profile: "engineering" + ) +) +``` + +You can omit `region` when that profile defines one in `~/.aws/config`. + +Pass temporary credentials directly, including the session token: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"), + secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"), + session_token: ENV["AWS_SESSION_TOKEN"] + ) +) +``` + +For credentials managed by your application, pass an AWS credential provider or a callable that returns `Aws::Credentials`. It is consulted before every request attempt, including retries: + +```ruby +credentials_provider = lambda do + Aws::Credentials.new( + ENV.fetch("AWS_ACCESS_KEY_ID"), + ENV.fetch("AWS_SECRET_ACCESS_KEY"), + ENV["AWS_SESSION_TOKEN"] + ) +end + +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + credentials_provider: credentials_provider + ) +) +``` + +The provider signs each finalized attempt with AWS SigV4 service name `bedrock-mantle`. Standard JSON API requests have replayable bodies and work normally. SigV4 rejects one-shot request streams before sending, and signed requests do not automatically follow redirects because a new target requires a new signature. Response streaming is unaffected. + +### Bearer authentication + +Pass a Bedrock bearer credential directly, set `AWS_BEARER_TOKEN_BEDROCK`, or use a callable to resolve a fresh token before every attempt: + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + api_key: ENV.fetch("BEDROCK_API_KEY") + ) +) +``` + +```ruby +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + token_provider: -> { refresh_bedrock_token } + ) +) +``` + +Bearer authentication does not load or require `aws-sdk-core`. Passing `api_key: nil` explicitly skips `AWS_BEARER_TOKEN_BEDROCK` and selects AWS authentication. + +## Security + +Use Bedrock from a server-side Ruby runtime. Prefer roles, profiles, SSO, and temporary credentials over long-lived static keys. Do not send AWS access keys, session tokens, bearer credentials, or signed authorization headers to browsers or application logs. diff --git a/examples/bedrock.rb b/examples/bedrock.rb new file mode 100755 index 000000000..5dd6401d2 --- /dev/null +++ b/examples/bedrock.rb @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/openai" + +client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: ENV["AWS_REGION"] || ENV["AWS_DEFAULT_REGION"] + ) +) + +response = client.responses.create( + model: ENV.fetch("BEDROCK_MODEL"), + input: "Say hello from Amazon Bedrock!" +) + +puts(response.output_text) diff --git a/lib/openai.rb b/lib/openai.rb index b35884f4e..7ff77d0be 100644 --- a/lib/openai.rb +++ b/lib/openai.rb @@ -53,6 +53,9 @@ require_relative "openai/request_options" require_relative "openai/file_part" require_relative "openai/errors" +require_relative "openai/provider" +require_relative "openai/internal/provider" +require_relative "openai/providers/bedrock" require_relative "openai/internal/transport/base_client" require_relative "openai/internal/transport/pooled_net_requester" require_relative "openai/client" diff --git a/lib/openai/client.rb b/lib/openai/client.rb index 930fa5657..699e17472 100644 --- a/lib/openai/client.rb +++ b/lib/openai/client.rb @@ -125,6 +125,8 @@ class Client < OpenAI::Internal::Transport::BaseClient # # @return [Hash{String=>String}] private def auth_headers(security:) + return {} if @provider_runtime + enabled_security = security.select { |_, enabled| enabled } headers = {bearer_auth:, admin_api_key_auth:}.slice(*enabled_security.keys).values.reduce({}, :merge) if headers.empty? && enabled_security.any? @@ -166,6 +168,13 @@ class Client < OpenAI::Internal::Transport::BaseClient true end + # @api private + private def prepare_request(request, redirect_count:, retry_count:) + preparer = @provider_runtime&.prepare_request + return super unless preparer + preparer.call(request) + end + # @api private private def send_request(request, redirect_count:, retry_count:, send_retry_header:) return super unless @workload_identity_auth @@ -218,6 +227,10 @@ class Client < OpenAI::Internal::Transport::BaseClient # # @param webhook_secret [String, nil] Defaults to `ENV["OPENAI_WEBHOOK_SECRET"]` # + # @param provider [OpenAI::Provider, nil] Configure a supported + # third-party provider. Provider authentication and routing cannot be combined + # with top-level `api_key`, `admin_api_key`, `workload_identity`, or `base_url`. + # # @param base_url [String, nil] Override the default base URL for the API, e.g., # `"https://api.example.com/v2/"`. Defaults to `ENV["OPENAI_BASE_URL"]` # @@ -229,25 +242,65 @@ class Client < OpenAI::Internal::Transport::BaseClient # # @param max_retry_delay [Float] def initialize( - api_key: ENV["OPENAI_API_KEY"], - admin_api_key: ENV["OPENAI_ADMIN_KEY"], + api_key: OpenAI::Internal::OMIT, + admin_api_key: OpenAI::Internal::OMIT, workload_identity: nil, - organization: ENV["OPENAI_ORG_ID"], - project: ENV["OPENAI_PROJECT_ID"], - webhook_secret: ENV["OPENAI_WEBHOOK_SECRET"], - base_url: ENV["OPENAI_BASE_URL"], + organization: OpenAI::Internal::OMIT, + project: OpenAI::Internal::OMIT, + webhook_secret: OpenAI::Internal::OMIT, + provider: nil, + base_url: OpenAI::Internal::OMIT, 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 ) + provider_runtime = nil + unless provider.nil? + provider_name = OpenAI::Internal::Provider.name(provider) + conflicts = { + api_key: api_key, + admin_api_key: admin_api_key, + workload_identity: workload_identity, + base_url: base_url + }.filter_map do |name, value| + name unless value.equal?(OpenAI::Internal::OMIT) || value.nil? + end + unless conflicts.empty? + formatted = conflicts.map { "`#{_1}`" }.join(", ") + message = + "`provider` cannot be combined with top-level #{formatted}. Move provider " \ + "authentication and routing options into `#{provider_name}(...)`." + raise ArgumentError, message + end + provider_runtime = OpenAI::Internal::Provider.configure(provider) + end + + api_key = ENV["OPENAI_API_KEY"] if api_key.equal?(OpenAI::Internal::OMIT) && provider_runtime.nil? + if admin_api_key.equal?(OpenAI::Internal::OMIT) && provider_runtime.nil? + admin_api_key = ENV["OPENAI_ADMIN_KEY"] + end + if organization.equal?(OpenAI::Internal::OMIT) && provider_runtime.nil? + organization = ENV["OPENAI_ORG_ID"] + end + project = ENV["OPENAI_PROJECT_ID"] if project.equal?(OpenAI::Internal::OMIT) && provider_runtime.nil? + webhook_secret = ENV["OPENAI_WEBHOOK_SECRET"] if webhook_secret.equal?(OpenAI::Internal::OMIT) + base_url = ENV["OPENAI_BASE_URL"] if base_url.equal?(OpenAI::Internal::OMIT) && provider_runtime.nil? + + api_key = nil if api_key.equal?(OpenAI::Internal::OMIT) + admin_api_key = nil if admin_api_key.equal?(OpenAI::Internal::OMIT) + organization = nil if organization.equal?(OpenAI::Internal::OMIT) + project = nil if project.equal?(OpenAI::Internal::OMIT) + webhook_secret = nil if webhook_secret.equal?(OpenAI::Internal::OMIT) + base_url = provider_runtime.base_url if provider_runtime + base_url = nil if base_url.equal?(OpenAI::Internal::OMIT) base_url ||= "https://api.openai.com/v1" if !api_key.nil? && !workload_identity.nil? raise ArgumentError, "`api_key` and `workload_identity` are mutually exclusive" end - if api_key.nil? && admin_api_key.nil? && workload_identity.nil? + if provider_runtime.nil? && api_key.nil? && admin_api_key.nil? && workload_identity.nil? raise ArgumentError, "Missing credentials. Please pass an `api_key`, `workload_identity`, `admin_api_key`, or set the `OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable." end @@ -256,7 +309,7 @@ def initialize( "openai-organization" => (@organization = organization&.to_s), "openai-project" => (@project = project&.to_s) } - custom_headers_env = ENV["OPENAI_CUSTOM_HEADERS"] + custom_headers_env = ENV["OPENAI_CUSTOM_HEADERS"] unless provider_runtime unless custom_headers_env.nil? parsed = {} custom_headers_env.split("\n").each do |line| @@ -280,6 +333,7 @@ def initialize( end @admin_api_key = admin_api_key&.to_s @webhook_secret = webhook_secret&.to_s + @provider_runtime = provider_runtime super( base_url: base_url, diff --git a/lib/openai/internal/provider.rb b/lib/openai/internal/provider.rb new file mode 100644 index 000000000..ea07e7b1f --- /dev/null +++ b/lib/openai/internal/provider.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module OpenAI + module Internal + # Private registry for OpenAI-owned provider configurations. + # + # Provider factories return opaque OpenAI::Provider instances whose public + # representation does not expose their definition. + module Provider + Runtime = Struct.new(:name, :base_url, :prepare_request, keyword_init: true) + + class << self + # @api private + def create(definition) + OpenAI::Provider.send(:new, definition.freeze).freeze + end + + # @api private + def name(provider) = definition(provider).name + + # @api private + def configure(provider) = definition(provider).configure + + private def definition(provider) + return provider.send(:definition) if provider.instance_of?(OpenAI::Provider) + raise ArgumentError, "Invalid provider. Providers must be created by an OpenAI provider factory." + end + end + end + end +end diff --git a/lib/openai/internal/transport/base_client.rb b/lib/openai/internal/transport/base_client.rb index 22db48eba..0c0236ada 100644 --- a/lib/openai/internal/transport/base_client.rb +++ b/lib/openai/internal/transport/base_client.rb @@ -232,6 +232,14 @@ def initialize( # @return [Hash{String=>String}] private def auth_headers = {} + # Apply final per-attempt request transformations after retry headers are + # set and immediately before the request reaches the transport. + # + # @api private + private def prepare_request(request, **_context) + request + end + # @api private # # @return [String] @@ -371,6 +379,24 @@ def initialize( (@initial_retry_delay * scale * jitter).clamp(0, @max_retry_delay) end + # @api private + private def raise_status_error!(url:, status:, headers:, response:, stream:) + decoded = Kernel.then do + OpenAI::Internal::Util.decode_content(headers, stream: stream, suppress_error: true) + ensure + self.class.reap_connection!(status, stream: stream) + end + + raise OpenAI::Errors::APIStatusError.for( + url: url, + status: status, + headers: headers, + body: decoded, + request: nil, + response: response + ) + end + # @api private # # @param request [Hash{Symbol=>Object}] . @@ -396,13 +422,23 @@ def initialize( # @raise [OpenAI::Errors::APIError] # @return [Array(Integer, Net::HTTPResponse, Enumerable)] def send_request(request, redirect_count:, retry_count:, send_retry_header:) - url, headers, max_retries, timeout = request.fetch_values(:url, :headers, :max_retries, :timeout) - input = {**request.except(:timeout), deadline: OpenAI::Internal::Util.monotonic_secs + timeout} - if send_retry_header - headers["x-stainless-retry-count"] = retry_count.to_s + request = request.merge( + headers: request.fetch(:headers).merge("x-stainless-retry-count" => retry_count.to_s) + ) end + prepared_request = prepare_request( + request, + redirect_count: redirect_count, + retry_count: retry_count + ) + url, max_retries, timeout = prepared_request.fetch_values(:url, :max_retries, :timeout) + input = { + **prepared_request.except(:timeout, :follow_redirects, :provider_auth), + deadline: OpenAI::Internal::Util.monotonic_secs + timeout + } + begin status, response, stream = @requester.execute(input) rescue OpenAI::Errors::APIConnectionError => e @@ -410,6 +446,25 @@ def send_request(request, redirect_count:, retry_count:, send_retry_header:) end headers = OpenAI::Internal::Util.normalized_headers(response&.each_header&.to_h) + terminal_status = + case status + in 300..399 + prepared_request[:follow_redirects] == false + in (400..) + retry_count >= max_retries || !self.class.should_retry?(status, headers: headers) + else + false + end + if terminal_status + raise_status_error!( + url: url, + status: status, + headers: headers, + response: response, + stream: stream + ) + end + case status in ..299 [status, response, stream] @@ -421,30 +476,19 @@ def send_request(request, redirect_count:, retry_count:, send_retry_header:) in 300..399 self.class.reap_connection!(status, stream: stream) - request = self.class.follow_redirect(request, status: status, response_headers: headers) + redirected_request = self.class.follow_redirect( + prepared_request, + status: status, + response_headers: headers + ) send_request( - request, + redirected_request, redirect_count: redirect_count + 1, retry_count: retry_count, send_retry_header: send_retry_header ) in OpenAI::Errors::APIConnectionError if retry_count >= max_retries raise status - in (400..) if retry_count >= max_retries || !self.class.should_retry?(status, headers: headers) - decoded = Kernel.then do - OpenAI::Internal::Util.decode_content(headers, stream: stream, suppress_error: true) - ensure - self.class.reap_connection!(status, stream: stream) - end - - raise OpenAI::Errors::APIStatusError.for( - url: url, - status: status, - headers: headers, - body: decoded, - request: nil, - response: response - ) in (400..) | OpenAI::Errors::APIConnectionError self.class.reap_connection!(status, stream: stream) diff --git a/lib/openai/provider.rb b/lib/openai/provider.rb new file mode 100644 index 000000000..9cd3935bc --- /dev/null +++ b/lib/openai/provider.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module OpenAI + # Opaque configuration for an OpenAI-supported third-party provider. + # + # Provider instances are created by factories in OpenAI::Providers. + class Provider + # @api private + def initialize(definition) = @definition = definition + + def inspect = "#" + + attr_reader(:definition) + private(:definition) + + private_class_method :new, :allocate + end +end diff --git a/lib/openai/providers/bedrock.rb b/lib/openai/providers/bedrock.rb new file mode 100644 index 000000000..01aa47c54 --- /dev/null +++ b/lib/openai/providers/bedrock.rb @@ -0,0 +1,489 @@ +# frozen_string_literal: true + +module OpenAI + module Providers + # @api private + module Bedrock + SERVICE = "bedrock-mantle" + CANONICAL_HOST = /\Abedrock-mantle\.([a-z0-9-]+)\.api\.aws\z/i + SIGNING_HEADERS = %w[authorization x-amz-content-sha256 x-amz-date x-amz-security-token].freeze + BEARER_AUTH_MARKER = :openai_bedrock_bearer + SIGV4_AUTH_MARKER = :openai_bedrock_sigv4 + MISSING_REGION_MESSAGE = + "Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` " \ + "or `AWS_DEFAULT_REGION`." + MISSING_CREDENTIALS_MESSAGE = + "Could not find credentials for Bedrock. Pass a bearer credential or AWS credentials " \ + "to `bedrock(...)`, set `AWS_BEARER_TOKEN_BEDROCK`, or configure the default AWS credential chain." + CREDENTIAL_RESOLUTION_MESSAGE = + "Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment " \ + "variables, or runtime identity configuration and try again." + NON_REPLAYABLE_BODY_MESSAGE = + "Bedrock SigV4 authentication requires a replayable request body. Buffer the body " \ + "before sending or use bearer authentication." + MISSING_DEPENDENCY_MESSAGE = + "Bedrock AWS authentication requires optional AWS dependencies. Add `gem \"aws-sdk-core\"` " \ + "to your Gemfile, run `bundle install`, and try again." + PARTIAL_STATIC_CREDENTIALS_MESSAGE = + "Static AWS credentials require both `access_key_id` and `secret_access_key`. " \ + "A `session_token` may only be used with both." + AMBIGUOUS_AWS_AUTH_MESSAGE = + "Bedrock authentication is ambiguous. Configure exactly one explicit AWS mode: " \ + "static credentials, profile, or credential provider." + AMBIGUOUS_AUTH_MESSAGE = + "Bedrock authentication is ambiguous. Configure exactly one explicit mode: bearer " \ + "credential, static AWS credentials, profile, or credential provider." + + class Definition + attr_reader :name + + def initialize( + region:, + base_url:, + bearer_provider:, + profile:, + access_key_id:, + secret_access_key:, + session_token:, + credentials_provider:, + default_chain: + ) + @name = "bedrock" + @region = region&.freeze + @base_url = base_url&.freeze + @bearer_provider = bearer_provider + @profile = profile&.freeze + @access_key_id = access_key_id&.freeze + @secret_access_key = secret_access_key&.freeze + @session_token = session_token&.freeze + @credentials_provider = credentials_provider + @default_chain = default_chain + end + + def configure + if @bearer_provider + base_url = Bedrock.resolve_base_url(@base_url, @region) + auth = BearerAuth.new(token_provider: @bearer_provider, base_url: base_url) + else + Bedrock.load_aws! + region = @region || Bedrock.profile_region(@profile) + raise ArgumentError, MISSING_REGION_MESSAGE if region.nil? + + base_url = Bedrock.resolve_base_url(@base_url, region) + credentials = + if @access_key_id + Aws::Credentials.new(@access_key_id, @secret_access_key, @session_token) + elsif @credentials_provider + CustomCredentialsProvider.new(@credentials_provider) + elsif @profile + ProfileCredentialsProvider.new(@profile, region: region) + else + DefaultCredentialsProvider.new + end + auth = SigV4Auth.new( + region: region, + base_url: base_url, + credentials_provider: credentials, + default_chain: @default_chain + ) + end + + OpenAI::Internal::Provider::Runtime.new( + name: name, + base_url: base_url, + prepare_request: auth.method(:prepare_request) + ) + end + end + + class BearerAuth + def initialize(token_provider:, base_url:) + @token_provider = token_provider + @base_url = URI(base_url) + end + + def prepare_request(request) + Bedrock.validate_origin!(request.fetch(:url), @base_url, action: "authenticate") + headers = Bedrock.provider_headers(request, marker: BEARER_AUTH_MARKER) + token = resolve_token + + request.merge( + headers: headers.merge("authorization" => "Bearer #{token}"), + provider_auth: BEARER_AUTH_MARKER + ) + end + + private def resolve_token + token = @token_provider.call + unless token.is_a?(String) && !token.strip.empty? + raise OpenAI::Errors::Error, + "The Bedrock bearer credential provider must return a non-empty string." + end + token + rescue OpenAI::Errors::Error + raise + rescue StandardError => e + raise OpenAI::Errors::Error.new("Failed to resolve a bearer credential for Bedrock."), cause: e + end + end + + class SigV4Auth + def initialize(region:, base_url:, credentials_provider:, default_chain:) + @region = region + @base_url = URI(base_url) + @default_chain = default_chain + @signer = Aws::Sigv4::Signer.new( + service: SERVICE, + region: region, + credentials_provider: credentials_provider + ) + end + + def prepare_request(request) + url = request.fetch(:url) + Bedrock.validate_origin!(url, @base_url, action: "sign") + Bedrock.validate_endpoint_region!(url, @region) + body = request[:body] + unless body.nil? || body.is_a?(String) + raise OpenAI::Errors::Error, NON_REPLAYABLE_BODY_MESSAGE + end + + headers = Bedrock.provider_headers(request, marker: SIGV4_AUTH_MARKER) + SIGNING_HEADERS.each { headers.delete(_1) } + signature = sign_request( + http_method: request.fetch(:method).to_s.upcase, + url: url.to_s, + headers: headers, + body: body + ) + + request.merge( + headers: OpenAI::Internal::Util.normalized_headers(headers, signature.headers), + follow_redirects: false, + provider_auth: SIGV4_AUTH_MARKER + ) + end + + private def sign_request(**options) + @signer.sign_request(**options) + rescue OpenAI::Errors::Error + raise + rescue StandardError => e + message = + if @default_chain + MISSING_CREDENTIALS_MESSAGE + else + CREDENTIAL_RESOLUTION_MESSAGE + end + raise OpenAI::Errors::Error.new(message), cause: e + end + end + + class CustomCredentialsProvider + def initialize(provider) = @provider = provider + + def credentials + value = @provider.respond_to?(:call) ? @provider.call : @provider.credentials + value = value.credentials if !value.respond_to?(:access_key_id) && value.respond_to?(:credentials) + Bedrock.validate_credentials!(value) + rescue OpenAI::Errors::Error + raise + rescue StandardError => e + raise OpenAI::Errors::Error.new(CREDENTIAL_RESOLUTION_MESSAGE), cause: e + end + end + + class DefaultCredentialsProvider + def initialize + @mutex = Mutex.new + @provider = nil + end + + def credentials + provider = @mutex.synchronize { @provider ||= Aws::CredentialProviderChain.new.resolve } + raise OpenAI::Errors::Error, MISSING_CREDENTIALS_MESSAGE unless provider + Bedrock.validate_credentials!(provider.credentials) + end + end + + class ProfileCredentialsProvider + def initialize(profile, region:) + @profile = profile + @region = region + @mutex = Mutex.new + @provider = nil + end + + def credentials + provider = @mutex.synchronize { @provider ||= resolve } + Bedrock.validate_credentials!(provider.credentials) + rescue OpenAI::Errors::Error + raise + rescue StandardError => e + raise OpenAI::Errors::Error.new(CREDENTIAL_RESOLUTION_MESSAGE), cause: e + end + + private def resolve + config = Aws.shared_config + providers = [] + if config.config_enabled? + providers << config.assume_role_web_identity_credentials_from_config( + profile: @profile, + region: @region + ) + providers << config.sso_credentials_from_config(profile: @profile) + providers << config.assume_role_credentials_from_config(profile: @profile, region: @region) + end + + begin + providers << Aws::SharedCredentials.new(profile_name: @profile) + rescue Aws::Errors::NoSuchProfileError + nil + end + + if config.config_enabled? + if config.respond_to?(:login_credentials_from_config) + providers << config.login_credentials_from_config(profile: @profile, region: @region) + end + process = config.credential_process(profile: @profile) + providers << Aws::ProcessCredentials.new([process]) if process + end + + providers.compact.find(&:set?) || raise(OpenAI::Errors::Error, CREDENTIAL_RESOLUTION_MESSAGE) + end + end + + class << self + def load_aws! + require("aws-sdk-core") + require("aws-sigv4") + rescue LoadError => e + raise OpenAI::Errors::Error.new(MISSING_DEPENDENCY_MESSAGE), cause: e + end + + def resolve_base_url(base_url, region) + return base_url if base_url + raise ArgumentError, MISSING_REGION_MESSAGE if region.nil? + "https://bedrock-mantle.#{region}.api.aws/v1" + end + + def normalize_base_url(base_url) + uri = URI(base_url) + unless uri.is_a?(URI::HTTP) && uri.host + raise ArgumentError, "The Bedrock `base_url` must be an absolute HTTP or HTTPS URL." + end + uri.path = uri.path.sub(%r{/responses(?:/.*)?\z}, "") + uri.path = "" if uri.path == "/" + uri.to_s.sub(%r{/\z}, "") + rescue URI::InvalidURIError => e + message = "The Bedrock `base_url` must be an absolute HTTP or HTTPS URL." + raise ArgumentError.new(message), cause: e + end + + def profile_region(profile) + configured_profile = profile || ENV["AWS_PROFILE"] || ENV["AWS_DEFAULT_PROFILE"] || "default" + region = Aws.shared_config.region(profile: configured_profile) + normalize_optional_string(region) + rescue StandardError => e + raise OpenAI::Errors::Error.new(CREDENTIAL_RESOLUTION_MESSAGE), cause: e + end + + def provider_headers(request, marker:) + headers = request.fetch(:headers).dup + if request[:provider_auth] == marker + headers.delete("authorization") + elsif headers.key?("authorization") + raise OpenAI::Errors::Error, + "Bedrock provider authentication cannot be combined with a custom `Authorization` header." + end + headers + end + + def validate_origin!(url, base_url, action:) + return if OpenAI::Internal::Util.uri_origin(url) == OpenAI::Internal::Util.uri_origin(base_url) + message = + "Refusing to #{action} a Bedrock request for an origin other than the configured " \ + "provider URL." + raise OpenAI::Errors::Error, + message + end + + def validate_endpoint_region!(url, region) + endpoint_region = CANONICAL_HOST.match(url.host)&.[](1) + return if endpoint_region.nil? || endpoint_region == region + message = + "The Bedrock endpoint region `#{endpoint_region}` does not match the SigV4 " \ + "region `#{region}`." + raise OpenAI::Errors::Error, + message + end + + def validate_credentials!(credentials) + access_key_id = credentials&.access_key_id + secret_access_key = credentials&.secret_access_key + session_token = credentials&.session_token if credentials.respond_to?(:session_token) + valid = + access_key_id.is_a?(String) && !access_key_id.strip.empty? && + secret_access_key.is_a?(String) && !secret_access_key.strip.empty? && + (session_token.nil? || (session_token.is_a?(String) && !session_token.strip.empty?)) + return credentials if valid + raise OpenAI::Errors::Error, CREDENTIAL_RESOLUTION_MESSAGE + end + + def normalize_optional_string(value) + return nil if value.nil? + return nil unless value.is_a?(String) + normalized = value.strip + normalized unless normalized.empty? + end + end + end + + class << self + # Configure the standard OpenAI client for Amazon Bedrock Mantle. + # + # Authentication precedence is an explicit bearer or AWS mode, then + # AWS_BEARER_TOKEN_BEDROCK, then the standard AWS credential chain. + # + # @param region [String, nil] AWS signing region. Defaults to `AWS_REGION`, + # `AWS_DEFAULT_REGION`, or the selected profile's configured region. + # + # @param base_url [String, nil] Bedrock API root. Defaults to + # `AWS_BEDROCK_BASE_URL` or the regional Bedrock Mantle endpoint. + # + # @param api_key [String, nil] Explicit Bedrock bearer credential. Passing + # `nil` explicitly skips the `AWS_BEARER_TOKEN_BEDROCK` fallback. + # + # @param token_provider [#call, nil] Callable returning a fresh bearer + # credential before each request attempt. + # + # @param access_key_id [String, nil] Explicit AWS access key ID. Must be + # paired with `secret_access_key`. + # + # @param secret_access_key [String, nil] Explicit AWS secret access key. + # + # @param session_token [String, nil] Optional session token for explicit + # temporary AWS credentials. + # + # @param profile [String, nil] Explicit AWS shared-config profile. + # + # @param credentials_provider [#credentials, #call, nil] Refreshable AWS + # credentials provider. A callable must return an AWS credential identity. + # + # @return [OpenAI::Provider] + def bedrock( + region: nil, + base_url: OpenAI::Internal::OMIT, + api_key: OpenAI::Internal::OMIT, + token_provider: nil, + access_key_id: nil, + secret_access_key: nil, + session_token: nil, + profile: nil, + credentials_provider: nil + ) + normalized_region = Bedrock.normalize_optional_string(region) + if !region.nil? && normalized_region.nil? + raise ArgumentError, "The Bedrock AWS `region` must not be empty." + end + normalized_region ||= Bedrock.normalize_optional_string(ENV["AWS_REGION"]) || + Bedrock.normalize_optional_string(ENV["AWS_DEFAULT_REGION"]) + + normalized_profile = Bedrock.normalize_optional_string(profile) + if !profile.nil? && normalized_profile.nil? + raise ArgumentError, "The Bedrock AWS `profile` must not be empty." + end + + configured_base_url = + if base_url.equal?(OpenAI::Internal::OMIT) + Bedrock.normalize_optional_string(ENV["AWS_BEDROCK_BASE_URL"]) + elsif base_url.nil? + nil + else + normalized = Bedrock.normalize_optional_string(base_url) + raise(ArgumentError, "The Bedrock `base_url` must not be empty.") unless normalized + normalized + end + configured_base_url = Bedrock.normalize_base_url(configured_base_url) if configured_base_url + + has_access_key = !access_key_id.nil? + has_secret_key = !secret_access_key.nil? + if has_access_key != has_secret_key || (!session_token.nil? && !has_access_key) + raise ArgumentError, Bedrock::PARTIAL_STATIC_CREDENTIALS_MESSAGE + end + normalized_access_key_id = Bedrock.normalize_optional_string(access_key_id) + normalized_secret_access_key = Bedrock.normalize_optional_string(secret_access_key) + normalized_session_token = Bedrock.normalize_optional_string(session_token) + if has_access_key && [normalized_access_key_id, normalized_secret_access_key].any?(&:nil?) + raise ArgumentError, + "Static AWS credentials require non-empty `access_key_id` and `secret_access_key` values." + end + if !session_token.nil? && normalized_session_token.nil? + raise ArgumentError, "A static AWS `session_token` must not be empty when provided." + end + + explicit_api_key = !api_key.equal?(OpenAI::Internal::OMIT) && !api_key.nil? + normalized_api_key = Bedrock.normalize_optional_string(api_key) + if explicit_api_key && normalized_api_key.nil? + raise ArgumentError, "The Bedrock bearer credential must not be empty." + end + if explicit_api_key && !token_provider.nil? + raise ArgumentError, + "The `api_key` and `token_provider` options are mutually exclusive. Configure only one." + end + if !token_provider.nil? && !token_provider.respond_to?(:call) + raise ArgumentError, "The Bedrock `token_provider` must respond to `call`." + end + if !credentials_provider.nil? && + !credentials_provider.respond_to?(:call) && + !credentials_provider.respond_to?(:credentials) + raise ArgumentError, + "The Bedrock `credentials_provider` must respond to `call` or `credentials`." + end + + explicit_bearer = explicit_api_key || !token_provider.nil? + aws_modes = [has_access_key, !normalized_profile.nil?, !credentials_provider.nil?].count(true) + if aws_modes > 1 + raise ArgumentError, Bedrock::AMBIGUOUS_AWS_AUTH_MESSAGE + end + if explicit_bearer && aws_modes.positive? + raise ArgumentError, Bedrock::AMBIGUOUS_AUTH_MESSAGE + end + + skip_environment_bearer = !api_key.equal?(OpenAI::Internal::OMIT) && api_key.nil? + environment_bearer = + !explicit_bearer && aws_modes.zero? && !skip_environment_bearer && + !Bedrock.normalize_optional_string(ENV["AWS_BEARER_TOKEN_BEDROCK"]).nil? + bearer_provider = + if explicit_api_key + normalized_api_key.freeze + -> { normalized_api_key } + elsif !token_provider.nil? + token_provider + elsif environment_bearer + lambda do + ENV["AWS_BEARER_TOKEN_BEDROCK"] || + raise(OpenAI::Errors::Error, Bedrock::MISSING_CREDENTIALS_MESSAGE) + end + end + + if bearer_provider && configured_base_url.nil? && normalized_region.nil? + raise ArgumentError, Bedrock::MISSING_REGION_MESSAGE + end + + definition = Bedrock::Definition.new( + region: normalized_region, + base_url: configured_base_url, + bearer_provider: bearer_provider, + profile: normalized_profile, + access_key_id: normalized_access_key_id, + secret_access_key: normalized_secret_access_key, + session_token: normalized_session_token, + credentials_provider: credentials_provider, + default_chain: aws_modes.zero? + ) + OpenAI::Internal::Provider.create(definition) + end + end + end +end diff --git a/rbi/openai/client.rbi b/rbi/openai/client.rbi index f8f04bb21..e171db182 100644 --- a/rbi/openai/client.rbi +++ b/rbi/openai/client.rbi @@ -132,14 +132,29 @@ module OpenAI private def admin_api_key_auth end + # @api private + sig do + override + .params( + request: OpenAI::Internal::Transport::BaseClient::RequestInput, + redirect_count: Integer, + retry_count: Integer + ) + .returns(OpenAI::Internal::Transport::BaseClient::RequestInput) + end + private def prepare_request(request, redirect_count:, retry_count:) + end + # Creates and returns a new client for interacting with the API. sig do params( api_key: T.nilable(String), admin_api_key: T.nilable(String), + workload_identity: T.nilable(OpenAI::Auth::WorkloadIdentity), organization: T.nilable(String), project: T.nilable(String), webhook_secret: T.nilable(String), + provider: T.nilable(OpenAI::Provider), base_url: T.nilable(String), max_retries: Integer, timeout: Float, @@ -152,12 +167,14 @@ module OpenAI api_key: ENV["OPENAI_API_KEY"], # Defaults to `ENV["OPENAI_ADMIN_KEY"]` admin_api_key: ENV["OPENAI_ADMIN_KEY"], + workload_identity: nil, # Defaults to `ENV["OPENAI_ORG_ID"]` organization: ENV["OPENAI_ORG_ID"], # Defaults to `ENV["OPENAI_PROJECT_ID"]` project: ENV["OPENAI_PROJECT_ID"], # Defaults to `ENV["OPENAI_WEBHOOK_SECRET"]` webhook_secret: ENV["OPENAI_WEBHOOK_SECRET"], + provider: nil, # Override the default base URL for the API, e.g., # `"https://api.example.com/v2/"`. Defaults to `ENV["OPENAI_BASE_URL"]` base_url: ENV["OPENAI_BASE_URL"], diff --git a/rbi/openai/internal/provider.rbi b/rbi/openai/internal/provider.rbi new file mode 100644 index 000000000..e36d5bc17 --- /dev/null +++ b/rbi/openai/internal/provider.rbi @@ -0,0 +1,51 @@ +# typed: strong + +module OpenAI + module Internal + module Provider + class Runtime + sig { returns(String) } + attr_accessor :name + + sig { returns(String) } + attr_accessor :base_url + + sig do + returns( + T.nilable( + T + .proc + .params( + request: OpenAI::Internal::Transport::BaseClient::RequestInput + ) + .returns(OpenAI::Internal::Transport::BaseClient::RequestInput) + ) + ) + end + attr_accessor :prepare_request + end + + class << self + sig do + params(definition: T.untyped).returns(OpenAI::Provider) + end + def create(definition) + end + + sig do + params(provider: OpenAI::Provider).returns(String) + end + def name(provider) + end + + sig do + params(provider: OpenAI::Provider).returns( + OpenAI::Internal::Provider::Runtime + ) + end + def configure(provider) + end + end + end + end +end diff --git a/rbi/openai/internal/transport/base_client.rbi b/rbi/openai/internal/transport/base_client.rbi index 8c533a27c..f3259fe68 100644 --- a/rbi/openai/internal/transport/base_client.rbi +++ b/rbi/openai/internal/transport/base_client.rbi @@ -188,6 +188,19 @@ module OpenAI private def auth_headers end + # @api private + sig do + overridable + .params( + request: OpenAI::Internal::Transport::BaseClient::RequestInput, + redirect_count: Integer, + retry_count: Integer + ) + .returns(OpenAI::Internal::Transport::BaseClient::RequestInput) + end + private def prepare_request(request, redirect_count:, retry_count:) + end + # @api private sig { returns(String) } private def user_agent @@ -220,6 +233,25 @@ module OpenAI private def retry_delay(headers, retry_count:) end + # @api private + sig do + params( + url: URI::Generic, + status: Integer, + headers: T::Hash[String, String], + response: Net::HTTPResponse, + stream: T::Enumerable[String] + ).returns(T.noreturn) + end + private def raise_status_error!( + url:, + status:, + headers:, + response:, + stream: + ) + end + # @api private sig do params( diff --git a/rbi/openai/provider.rbi b/rbi/openai/provider.rbi new file mode 100644 index 000000000..85b4eb48b --- /dev/null +++ b/rbi/openai/provider.rbi @@ -0,0 +1,9 @@ +# typed: strong + +module OpenAI + class Provider + sig { returns(String) } + def inspect + end + end +end diff --git a/rbi/openai/providers.rbi b/rbi/openai/providers.rbi new file mode 100644 index 000000000..97871322d --- /dev/null +++ b/rbi/openai/providers.rbi @@ -0,0 +1,33 @@ +# typed: strong + +module OpenAI + module Providers + class << self + sig do + params( + region: T.nilable(String), + base_url: T.nilable(String), + api_key: T.nilable(String), + token_provider: T.nilable(T.proc.returns(String)), + access_key_id: T.nilable(String), + secret_access_key: T.nilable(String), + session_token: T.nilable(String), + profile: T.nilable(String), + credentials_provider: T.untyped + ).returns(OpenAI::Provider) + end + def bedrock( + region: nil, + base_url: nil, + api_key: nil, + token_provider: nil, + access_key_id: nil, + secret_access_key: nil, + session_token: nil, + profile: nil, + credentials_provider: nil + ) + end + end + end +end diff --git a/sig/openai/client.rbs b/sig/openai/client.rbs index d644dcdf8..98e3d0ff5 100644 --- a/sig/openai/client.rbs +++ b/sig/openai/client.rbs @@ -67,13 +67,19 @@ module OpenAI attr_reader videos: OpenAI::Resources::Videos private def auth_headers: ( - security: { bearer_auth?: bool, admin_api_key_auth?: bool } + security: { :bearer_auth? => bool, :admin_api_key_auth? => bool } ) -> ::Hash[String, String] private def bearer_auth: -> ::Hash[String, String] private def admin_api_key_auth: -> ::Hash[String, String] + private def prepare_request: ( + OpenAI::Internal::Transport::BaseClient::request_input request, + redirect_count: Integer, + retry_count: Integer + ) -> OpenAI::Internal::Transport::BaseClient::request_input + def initialize: ( ?api_key: String?, ?admin_api_key: String?, @@ -81,6 +87,7 @@ module OpenAI ?organization: String?, ?project: String?, ?webhook_secret: String?, + ?provider: OpenAI::Provider?, ?base_url: String?, ?max_retries: Integer, ?timeout: Float, diff --git a/sig/openai/internal/provider.rbs b/sig/openai/internal/provider.rbs new file mode 100644 index 000000000..2e367b9ae --- /dev/null +++ b/sig/openai/internal/provider.rbs @@ -0,0 +1,29 @@ +module OpenAI + module Internal + module Provider + class Runtime + attr_accessor name: String + attr_accessor base_url: String + attr_accessor prepare_request: (^( + OpenAI::Internal::Transport::BaseClient::request_input + ) -> OpenAI::Internal::Transport::BaseClient::request_input)? + + def initialize: ( + name: String, + base_url: String, + prepare_request: (^( + OpenAI::Internal::Transport::BaseClient::request_input + ) -> OpenAI::Internal::Transport::BaseClient::request_input)? + ) -> void + end + + def self.create: ( + untyped definition + ) -> OpenAI::Provider + def self.name: (OpenAI::Provider provider) -> String + def self.configure: ( + OpenAI::Provider provider + ) -> OpenAI::Internal::Provider::Runtime + end + end +end diff --git a/sig/openai/internal/transport/base_client.rbs b/sig/openai/internal/transport/base_client.rbs index 632b4ab74..7accdaddf 100644 --- a/sig/openai/internal/transport/base_client.rbs +++ b/sig/openai/internal/transport/base_client.rbs @@ -20,7 +20,7 @@ module OpenAI page: Class?, stream: Class?, model: OpenAI::Internal::Type::Converter::input?, - security: { bearer_auth?: bool, admin_api_key_auth?: bool }?, + security: { :bearer_auth? => bool, :admin_api_key_auth? => bool }?, options: OpenAI::request_opts? } type request_input = @@ -30,7 +30,9 @@ module OpenAI headers: ::Hash[String, String], body: top, max_retries: Integer, - timeout: Float + timeout: Float, + :follow_redirects? => bool, + :provider_auth? => Symbol } MAX_REDIRECTS: 20 @@ -88,6 +90,12 @@ module OpenAI private def auth_headers: -> ::Hash[String, String] + private def prepare_request: ( + OpenAI::Internal::Transport::BaseClient::request_input request, + redirect_count: Integer, + retry_count: Integer + ) -> OpenAI::Internal::Transport::BaseClient::request_input + private def user_agent: -> String private def generate_idempotency_key: -> String @@ -102,6 +110,14 @@ module OpenAI retry_count: Integer ) -> Float + private def raise_status_error!: ( + url: URI::Generic, + status: Integer, + headers: ::Hash[String, String], + response: top, + stream: Enumerable[String] + ) -> bot + def send_request: ( OpenAI::Internal::Transport::BaseClient::request_input request, redirect_count: Integer, @@ -124,7 +140,7 @@ module OpenAI ?page: Class?, ?stream: Class?, ?model: OpenAI::Internal::Type::Converter::input?, - ?security: { bearer_auth?: bool, admin_api_key_auth?: bool }?, + ?security: { :bearer_auth? => bool, :admin_api_key_auth? => bool }?, ?options: OpenAI::request_opts? ) -> top diff --git a/sig/openai/provider.rbs b/sig/openai/provider.rbs new file mode 100644 index 000000000..11552320e --- /dev/null +++ b/sig/openai/provider.rbs @@ -0,0 +1,5 @@ +module OpenAI + class Provider + def inspect: -> String + end +end diff --git a/sig/openai/providers.rbs b/sig/openai/providers.rbs new file mode 100644 index 000000000..d59fbb057 --- /dev/null +++ b/sig/openai/providers.rbs @@ -0,0 +1,15 @@ +module OpenAI + module Providers + def self.bedrock: ( + ?region: String?, + ?base_url: String?, + ?api_key: String?, + ?token_provider: (^-> String)?, + ?access_key_id: String?, + ?secret_access_key: String?, + ?session_token: String?, + ?profile: String?, + ?credentials_provider: untyped + ) -> OpenAI::Provider + end +end diff --git a/test/fixtures/bedrock/v1/sigv4.json b/test/fixtures/bedrock/v1/sigv4.json new file mode 100644 index 000000000..bb5f4d16e --- /dev/null +++ b/test/fixtures/bedrock/v1/sigv4.json @@ -0,0 +1,23 @@ +{ + "$comment": "Uses AWS SigV4 documentation test credentials. These are public examples, not real AWS credentials.", + "signingDate": "2025-01-02T03:04:05.000Z", + "region": "us-east-1", + "service": "bedrock-mantle", + "credentials": { + "accessKeyId": "AKIDEXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "sessionToken": "fixture-session-token" + }, + "request": { + "method": "POST", + "url": "https://bedrock-mantle.us-east-1.api.aws/v1/responses", + "body": "{\"model\":\"gpt-4o\",\"input\":\"hello\"}", + "contentType": "application/json" + }, + "expected": { + "date": "20250102T030405Z", + "payloadHash": "50329e51ad520f21b77bad0b01999930ff556cd1bf18434701251ba6c9f877bc", + "canonicalRequestHash": "af8212c327679e6b6a7092b129c24b82a921bf85bcce1fbd0126acded824e489", + "authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20250102/us-east-1/bedrock-mantle/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=b81279dd7a01b9681012a7cf068f8ab7f663b85f0742741b509315ad22e32175" + } +} diff --git a/test/openai/providers/bedrock_test.rb b/test/openai/providers/bedrock_test.rb new file mode 100644 index 000000000..3a65c9e64 --- /dev/null +++ b/test/openai/providers/bedrock_test.rb @@ -0,0 +1,672 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +require "aws-sdk-core" +require "fileutils" +require "tmpdir" + +class OpenAI::Test::BedrockProviderTest < Minitest::Test + extend Minitest::Serial + include WebMock::API + + ENVIRONMENT_VARIABLES = %w[ + AWS_ACCESS_KEY_ID + AWS_BEARER_TOKEN_BEDROCK + AWS_BEDROCK_BASE_URL + AWS_CONFIG_FILE + AWS_DEFAULT_PROFILE + AWS_DEFAULT_REGION + AWS_EC2_METADATA_DISABLED + AWS_PROFILE + AWS_REGION + AWS_SDK_CONFIG_OPT_OUT + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN + AWS_SHARED_CREDENTIALS_FILE + OPENAI_ADMIN_KEY + OPENAI_API_KEY + OPENAI_BASE_URL + OPENAI_CUSTOM_HEADERS + OPENAI_ORG_ID + OPENAI_PROJECT_ID + ].freeze + + def before_all + super + WebMock.enable! + end + + def setup + super + @environment = ENVIRONMENT_VARIABLES.to_h { [_1, ENV[_1]] } + ENVIRONMENT_VARIABLES.each { ENV.delete(_1) } + @aws_dir = Dir.mktmpdir("openai-bedrock-test") + ENV["AWS_SHARED_CREDENTIALS_FILE"] = File.join(@aws_dir, "credentials") + ENV["AWS_CONFIG_FILE"] = File.join(@aws_dir, "config") + ENV["AWS_EC2_METADATA_DISABLED"] = "true" + File.write(ENV.fetch("AWS_SHARED_CREDENTIALS_FILE"), "") + File.write(ENV.fetch("AWS_CONFIG_FILE"), "") + reset_shared_config + end + + def teardown + Thread.current.thread_variable_set(:time_now, nil) + WebMock.reset! + FileUtils.rm_rf(@aws_dir) + @environment.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + reset_shared_config + super + end + + def after_all + WebMock.disable! + super + end + + def test_bearer_provider_owns_endpoint_and_authentication + stub_request(:get, "https://bedrock-mantle.us-east-1.api.aws/v1/models") + .to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + client.request({method: :get, path: "models"}) + + assert_equal("https://bedrock-mantle.us-east-1.api.aws/v1", client.base_url.to_s) + assert_requested(:get, "https://bedrock-mantle.us-east-1.api.aws/v1/models") do |request| + assert_equal("Bearer bedrock-token", request.headers["Authorization"]) + end + end + + def test_provider_ignores_ambient_openai_configuration + ENV["OPENAI_API_KEY"] = "openai-key" + ENV["OPENAI_ADMIN_KEY"] = "openai-admin-key" + ENV["OPENAI_BASE_URL"] = "https://api.example.com/v1" + ENV["OPENAI_ORG_ID"] = "org-example" + ENV["OPENAI_PROJECT_ID"] = "project-example" + ENV["OPENAI_CUSTOM_HEADERS"] = "x-openai-custom: should-not-leak" + url = "https://bedrock-mantle.us-east-1.api.aws/v1/models" + stub_request(:get, url).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + client.request({method: :get, path: "models"}) + + assert_requested(:get, url) do |request| + headers = request.headers.transform_keys(&:downcase) + assert_equal("Bearer bedrock-token", headers["authorization"]) + refute_includes(headers, "openai-organization") + refute_includes(headers, "openai-project") + refute_includes(headers, "x-openai-custom") + end + end + + def test_provider_rejects_top_level_authentication_and_routing + provider = OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + + error = assert_raises(ArgumentError) do + OpenAI::Client.new(provider: provider, api_key: "openai-key", base_url: "https://example.com") + end + + assert_match(/`api_key`, `base_url`/, error.message) + assert_match(/`bedrock\(\.\.\.\)`/, error.message) + end + + def test_default_chain_reads_the_aws_credentials_file + ENV["AWS_BEARER_TOKEN_BEDROCK"] = "ignored-bearer-token" + File.write( + ENV.fetch("AWS_SHARED_CREDENTIALS_FILE"), + <<~INI + [default] + aws_access_key_id = file-access-key + aws_secret_access_key = file-secret-key + aws_session_token = file-session-token + INI + ) + url = "https://bedrock-mantle.us-east-1.api.aws/v1/models" + stub_request(:get, url).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-east-1", api_key: nil), + max_retries: 0 + ) + client.request({method: :get, path: "models"}) + + assert_requested(:get, url) do |request| + assert_includes(request.headers.fetch("Authorization"), "Credential=file-access-key/") + assert_equal("file-session-token", request.headers["X-Amz-Security-Token"]) + end + end + + def test_default_chain_reports_missing_credentials + runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", api_key: nil) + ) + + error = assert_raises(OpenAI::Errors::Error) do + runtime.prepare_request.call(bedrock_request) + end + + assert_equal(OpenAI::Providers::Bedrock::MISSING_CREDENTIALS_MESSAGE, error.message) + end + + def test_named_profile_resolves_credentials_and_region_from_shared_config + ENV["AWS_BEARER_TOKEN_BEDROCK"] = "ignored-bearer-token" + File.write( + ENV.fetch("AWS_SHARED_CREDENTIALS_FILE"), + <<~INI + [engineering] + aws_access_key_id = profile-access-key + aws_secret_access_key = profile-secret-key + INI + ) + File.write( + ENV.fetch("AWS_CONFIG_FILE"), + <<~INI + [profile engineering] + region = us-west-2 + INI + ) + reset_shared_config + url = "https://bedrock-mantle.us-west-2.api.aws/v1/models" + stub_request(:get, url).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new(provider: OpenAI::Providers.bedrock(profile: "engineering")) + client.request({method: :get, path: "models"}) + + assert_requested(:get, url) do |request| + assert_includes(request.headers.fetch("Authorization"), "Credential=profile-access-key/") + assert_includes(request.headers.fetch("Authorization"), "/us-west-2/bedrock-mantle/aws4_request") + end + end + + def test_named_profile_reads_shared_credentials_when_config_is_disabled + ENV["AWS_SDK_CONFIG_OPT_OUT"] = "true" + File.write( + ENV.fetch("AWS_SHARED_CREDENTIALS_FILE"), + <<~INI + [engineering] + aws_access_key_id = profile-access-key + aws_secret_access_key = profile-secret-key + INI + ) + reset_shared_config + url = "https://bedrock-mantle.us-west-2.api.aws/v1/models" + stub_request(:get, url).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-west-2", profile: "engineering") + ) + client.request({method: :get, path: "models"}) + + assert_requested(:get, url) do |request| + assert_includes(request.headers.fetch("Authorization"), "Credential=profile-access-key/") + end + end + + def test_missing_named_profile_reports_a_credential_resolution_error + error = assert_raises(ArgumentError) do + OpenAI::Client.new(provider: OpenAI::Providers.bedrock(profile: "missing")) + end + assert_equal(OpenAI::Providers::Bedrock::MISSING_REGION_MESSAGE, error.message) + + runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", profile: "missing") + ) + + error = assert_raises(OpenAI::Errors::Error) do + runtime.prepare_request.call(bedrock_request) + end + + assert_equal(OpenAI::Providers::Bedrock::CREDENTIAL_RESOLUTION_MESSAGE, error.message) + end + + def test_default_chain_honors_aws_profile + ENV["AWS_PROFILE"] = "selected" + File.write( + ENV.fetch("AWS_SHARED_CREDENTIALS_FILE"), + <<~INI + [selected] + aws_access_key_id = selected-access-key + aws_secret_access_key = selected-secret-key + INI + ) + File.write( + ENV.fetch("AWS_CONFIG_FILE"), + <<~INI + [profile selected] + region = us-east-2 + INI + ) + reset_shared_config + url = "https://bedrock-mantle.us-east-2.api.aws/v1/models" + stub_request(:get, url).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new(provider: OpenAI::Providers.bedrock(api_key: nil)) + client.request({method: :get, path: "models"}) + + assert_requested(:get, url) do |request| + assert_includes(request.headers.fetch("Authorization"), "Credential=selected-access-key/") + end + end + + def test_endpoint_precedence_and_normalization + ENV["AWS_BEDROCK_BASE_URL"] = "https://environment.example/openai/v1/responses/response-id" + + environment_client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + derived_client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-west-2", + base_url: nil, + api_key: "bedrock-token" + ) + ) + custom_client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + base_url: "https://explicit.example/openai/v1", + api_key: "bedrock-token" + ) + ) + + assert_equal("https://environment.example/openai/v1", environment_client.base_url.to_s) + assert_equal("https://bedrock-mantle.us-west-2.api.aws/v1", derived_client.base_url.to_s) + assert_equal("https://explicit.example/openai/v1", custom_client.base_url.to_s) + + sigv4_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + base_url: "https://gateway.example/openai/v1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + ) + prepared = sigv4_runtime.prepare_request.call( + bedrock_request("https://gateway.example/openai/v1/models") + ) + assert_includes(prepared.dig(:headers, "authorization"), "bedrock-mantle/aws4_request") + end + + def test_sigv4_matches_the_shared_fixture + fixture = JSON.parse( + File.read(File.expand_path("../../fixtures/bedrock/v1/sigv4.json", __dir__)) + ) + credentials = fixture.fetch("credentials") + provider = OpenAI::Providers.bedrock( + region: fixture.fetch("region"), + access_key_id: credentials.fetch("accessKeyId"), + secret_access_key: credentials.fetch("secretAccessKey"), + session_token: credentials.fetch("sessionToken") + ) + runtime = OpenAI::Internal::Provider.configure(provider) + request_fixture = fixture.fetch("request") + request = { + method: request_fixture.fetch("method").downcase.to_sym, + url: URI(request_fixture.fetch("url")), + headers: {"content-type" => request_fixture.fetch("contentType")}, + body: request_fixture.fetch("body") + } + + Thread.current.thread_variable_set(:time_now, Time.iso8601(fixture.fetch("signingDate"))) + prepared = runtime.prepare_request.call(request) + headers = prepared.fetch(:headers) + expected = fixture.fetch("expected") + + assert_equal(expected.fetch("date"), headers["x-amz-date"]) + assert_equal(expected.fetch("payloadHash"), headers["x-amz-content-sha256"]) + assert_equal(credentials.fetch("sessionToken"), headers["x-amz-security-token"]) + assert_equal(expected.fetch("authorization"), headers["authorization"]) + assert_equal(false, prepared.fetch(:follow_redirects)) + end + + def test_sigv4_signs_the_serialized_body_sent_by_the_transport + url = "https://bedrock-mantle.us-east-1.api.aws/v1/responses" + requests = [] + stub_request(:post, url).to_return do |request| + requests << request + {status: 200, body: "{}", headers: {"content-type" => "application/json"}} + end + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-east-1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + ) + + client.request( + { + method: :post, + path: "responses", + body: {model: "openai.test-model", input: "hello"} + } + ) + + request = requests.fetch(0) + assert_equal(Digest::SHA256.hexdigest(request.body), request.headers["X-Amz-Content-Sha256"]) + assert_includes(request.headers.fetch("Authorization"), "bedrock-mantle/aws4_request") + end + + def test_custom_credentials_are_refreshed_and_request_is_resigned_on_retry + calls = 0 + credential_provider = lambda do + calls += 1 + Aws::Credentials.new("retry-access-#{calls}", "retry-secret-#{calls}") + end + url = "https://bedrock-mantle.us-east-1.api.aws/v1/models" + authorizations = [] + retry_counts = [] + stub_request(:get, url).to_return do |request| + authorizations << request.headers.fetch("Authorization") + retry_counts << request.headers.fetch("X-Stainless-Retry-Count") + { + status: authorizations.length == 1 ? 500 : 200, + body: "{}", + headers: {"content-type" => "application/json"} + } + end + + client = OpenAI::Client.new( + provider: OpenAI::Providers.bedrock( + region: "us-east-1", + credentials_provider: credential_provider + ), + max_retries: 1, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + client.request({method: :get, path: "models"}) + + assert_equal(2, calls) + assert_includes(authorizations[0], "Credential=retry-access-1/") + assert_includes(authorizations[1], "Credential=retry-access-2/") + assert_equal(%w[0 1], retry_counts) + end + + def test_custom_credential_provider_shapes_and_errors + credentials = Aws::Credentials.new("custom-access", "custom-secret") + object_provider = Struct.new(:credentials).new(credentials) + nested_provider = Struct.new(:credentials).new(credentials) + + [object_provider, -> { nested_provider }].each do |credential_provider| + runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + credentials_provider: credential_provider + ) + ) + prepared = runtime.prepare_request.call(bedrock_request) + + assert_includes(prepared.dig(:headers, "authorization"), "Credential=custom-access/") + end + + [-> { Aws::Credentials.new("", "custom-secret") }, -> {}].each do |credential_provider| + invalid_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + credentials_provider: credential_provider + ) + ) + error = assert_raises(OpenAI::Errors::Error) do + invalid_runtime.prepare_request.call(bedrock_request) + end + assert_equal(OpenAI::Providers::Bedrock::CREDENTIAL_RESOLUTION_MESSAGE, error.message) + end + + failing_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + credentials_provider: -> { raise "credential provider failed" } + ) + ) + error = assert_raises(OpenAI::Errors::Error) do + failing_runtime.prepare_request.call(bedrock_request) + end + assert_equal(OpenAI::Providers::Bedrock::CREDENTIAL_RESOLUTION_MESSAGE, error.message) + assert_equal("credential provider failed", error.cause.message) + end + + def test_bearer_provider_refreshes_the_environment_token_per_attempt + ENV["AWS_BEARER_TOKEN_BEDROCK"] = "first-token" + provider = OpenAI::Providers.bedrock(region: "us-east-1") + runtime = OpenAI::Internal::Provider.configure(provider) + request = { + method: :get, + url: URI("https://bedrock-mantle.us-east-1.api.aws/v1/models"), + headers: {}, + body: nil + } + + first = runtime.prepare_request.call(request) + ENV["AWS_BEARER_TOKEN_BEDROCK"] = "second-token" + second = runtime.prepare_request.call(request) + + assert_equal("Bearer first-token", first.dig(:headers, "authorization")) + assert_equal("Bearer second-token", second.dig(:headers, "authorization")) + end + + def test_bearer_provider_is_refreshed_and_validated + tokens = %w[first-token second-token] + runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", token_provider: -> { tokens.shift }) + ) + + first = runtime.prepare_request.call(bedrock_request) + second = runtime.prepare_request.call(first) + + assert_equal("Bearer second-token", second.dig(:headers, "authorization")) + + invalid_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", token_provider: -> { " " }) + ) + error = assert_raises(OpenAI::Errors::Error) do + invalid_runtime.prepare_request.call(bedrock_request) + end + assert_match(/must return a non-empty string/, error.message) + + failing_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + token_provider: -> { raise "token provider failed" } + ) + ) + error = assert_raises(OpenAI::Errors::Error) do + failing_runtime.prepare_request.call(bedrock_request) + end + assert_equal("Failed to resolve a bearer credential for Bedrock.", error.message) + assert_equal("token provider failed", error.cause.message) + end + + def test_provider_rejects_cross_origin_authentication + runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + request = bedrock_request("https://example.com/openai/v1/models") + + error = assert_raises(OpenAI::Errors::Error) { runtime.prepare_request.call(request) } + + assert_match(/Refusing to authenticate.*other than the configured provider URL/, error.message) + end + + def test_provider_rejects_custom_authorization_and_non_replayable_bodies + bearer_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + custom_auth = { + method: :get, + url: URI("https://bedrock-mantle.us-east-1.api.aws/v1/models"), + headers: {"authorization" => "Bearer custom"}, + body: nil + } + error = assert_raises(OpenAI::Errors::Error) { bearer_runtime.prepare_request.call(custom_auth) } + assert_match(/custom `Authorization` header/, error.message) + + sigv4_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + ) + streaming_body = custom_auth.merge(headers: {}, method: :post, body: StringIO.new("body")) + error = assert_raises(OpenAI::Errors::Error) { sigv4_runtime.prepare_request.call(streaming_body) } + assert_match(/replayable request body/, error.message) + end + + def test_sigv4_rejects_redirects_and_mismatched_canonical_regions + provider = OpenAI::Providers.bedrock( + region: "us-east-1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + source = "https://bedrock-mantle.us-east-1.api.aws/v1/models" + target = "https://bedrock-mantle.us-east-1.api.aws/v1/redirected" + stub_request(:get, source).to_return(status: 307, headers: {"location" => target}, body: "") + stub_request(:get, target).to_return_json(status: 200, body: {}) + + client = OpenAI::Client.new(provider: provider, max_retries: 0) + error = assert_raises(OpenAI::Errors::APIStatusError) do + client.request({method: :get, path: "models"}) + end + assert_equal(307, error.status) + assert_not_requested(:get, target) + + mismatched = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + base_url: "https://bedrock-mantle.us-west-2.api.aws/v1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + ) + request = { + method: :get, + url: URI("https://bedrock-mantle.us-west-2.api.aws/v1/models"), + headers: {}, + body: nil + } + error = assert_raises(OpenAI::Errors::Error) { mismatched.prepare_request.call(request) } + assert_match(/region `us-west-2` does not match.*`us-east-1`/, error.message) + end + + def test_authentication_modes_are_validated + assert_raises(ArgumentError) do + OpenAI::Providers.bedrock(region: "us-east-1", access_key_id: "access-key") + end + assert_raises(ArgumentError) do + OpenAI::Providers.bedrock( + region: "us-east-1", + api_key: "bedrock-token", + profile: "engineering" + ) + end + assert_raises(ArgumentError) do + OpenAI::Providers.bedrock( + region: "us-east-1", + access_key_id: "access-key", + secret_access_key: "secret-key", + profile: "engineering" + ) + end + end + + def test_provider_handles_are_opaque_and_reject_foreign_values + provider = OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + + assert_instance_of(OpenAI::Provider, provider) + assert_equal("#", provider.inspect) + assert_predicate(provider, :frozen?) + assert_raises(NoMethodError) { provider.definition } + assert_raises(NoMethodError) { OpenAI::Provider.new(Object.new) } + + error = assert_raises(ArgumentError) do + OpenAI::Internal::Provider.name(Object.new) + end + assert_match(/Invalid provider/, error.message) + + error = assert_raises(ArgumentError) { OpenAI::Client.new(provider: false) } + assert_match(/Invalid provider/, error.message) + end + + def test_configuration_values_are_validated + cases = [ + [{region: " "}, /AWS `region` must not be empty/], + [{region: Object.new}, /AWS `region` must not be empty/], + [{region: "us-east-1", profile: " "}, /AWS `profile` must not be empty/], + [{region: "us-east-1", base_url: " "}, /`base_url` must not be empty/], + [{region: "us-east-1", base_url: "relative", api_key: "token"}, /absolute HTTP or HTTPS/], + [{region: "us-east-1", base_url: "https://[", api_key: "token"}, /absolute HTTP or HTTPS/], + [ + {region: "us-east-1", access_key_id: " ", secret_access_key: "secret"}, + /require non-empty/ + ], + [ + { + region: "us-east-1", + access_key_id: "access", + secret_access_key: "secret", + session_token: " " + }, + /`session_token` must not be empty/ + ], + [{region: "us-east-1", session_token: "token"}, /require both/], + [{region: "us-east-1", api_key: " "}, /bearer credential must not be empty/], + [ + {region: "us-east-1", api_key: "token", token_provider: -> { "token" }}, + /mutually exclusive/ + ], + [{region: "us-east-1", token_provider: Object.new}, /must respond to `call`/], + [{region: "us-east-1", token_provider: false}, /must respond to `call`/], + [{region: "us-east-1", credentials_provider: Object.new}, /must respond to `call` or `credentials`/], + [{region: "us-east-1", credentials_provider: false}, /must respond to `call` or `credentials`/], + [{api_key: "token"}, /requires an AWS region/] + ] + + cases.each do |options, message| + error = assert_raises(ArgumentError, options.inspect) do + OpenAI::Providers.bedrock(**options) + end + assert_match(message, error.message, options.inspect) + end + + provider = OpenAI::Providers.bedrock( + base_url: "https://example.com/", + api_key: "token" + ) + client = OpenAI::Client.new(provider: provider) + assert_equal("https://example.com", client.base_url.to_s) + end + + def test_internal_request_errors_are_not_reported_as_credential_errors + bearer_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock(region: "us-east-1", api_key: "bedrock-token") + ) + error = assert_raises(KeyError) do + bearer_runtime.prepare_request.call(bedrock_request.except(:url)) + end + assert_match(/url/, error.message) + + sigv4_runtime = OpenAI::Internal::Provider.configure( + OpenAI::Providers.bedrock( + region: "us-east-1", + access_key_id: "access-key", + secret_access_key: "secret-key" + ) + ) + error = assert_raises(KeyError) do + sigv4_runtime.prepare_request.call(bedrock_request.except(:method)) + end + assert_match(/method/, error.message) + end + + private def reset_shared_config + Aws.instance_variable_set(:@shared_config, nil) + end + + private def bedrock_request(url = "https://bedrock-mantle.us-east-1.api.aws/v1/models") + {method: :get, url: URI(url), headers: {}, body: nil} + end +end