diff --git a/lib/ruby_llm/protocols/converse/chat.rb b/lib/ruby_llm/protocols/converse/chat.rb index c3cdbe493..c9f41d273 100644 --- a/lib/ruby_llm/protocols/converse/chat.rb +++ b/lib/ruby_llm/protocols/converse/chat.rb @@ -12,7 +12,20 @@ module Chat module_function def completion_url - "/model/#{@model.id}/converse" + "/model/#{escape_model_id(@model.id)}/converse" + end + + # An application inference profile is referenced by its ARN, which contains a "/" + # (".../application-inference-profile/"). Left raw, that "/" is parsed as a path + # separator in both the request URL and the SigV4 canonical path (Auth#canonical_uri), + # so AWS receives a truncated, invalid modelId ("The provided model identifier is + # invalid"). Percent-encoding the "/" keeps the id a single path segment; canonical_uri + # then re-encodes per segment, double-encoding it as SigV4 requires for non-S3 services, + # so the signed and sent paths stay consistent. This is a no-op for ordinary model ids, + # which contain no "/"; other characters such as ":" (e.g. "...-v1:0") are valid within + # a path segment and continue to be handled by canonical_uri unchanged. + def escape_model_id(model_id) + model_id.to_s.gsub('/', '%2F') end # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument diff --git a/lib/ruby_llm/protocols/converse/streaming.rb b/lib/ruby_llm/protocols/converse/streaming.rb index c28d7618d..b1d1b13ef 100644 --- a/lib/ruby_llm/protocols/converse/streaming.rb +++ b/lib/ruby_llm/protocols/converse/streaming.rb @@ -14,7 +14,7 @@ module Streaming private def stream_url - "/model/#{@model.id}/converse-stream" + "/model/#{escape_model_id(@model.id)}/converse-stream" end def stream_response(payload, additional_headers = {}, &block) diff --git a/spec/ruby_llm/providers/bedrock_spec.rb b/spec/ruby_llm/providers/bedrock_spec.rb index 1fedc7c0d..1db20efb2 100644 --- a/spec/ruby_llm/providers/bedrock_spec.rb +++ b/spec/ruby_llm/providers/bedrock_spec.rb @@ -112,4 +112,50 @@ def credential_provider(credentials = self.credentials) expect(headers['X-Amz-Security-Token']).to eq('provider-token') end end + + describe 'model id path encoding' do + # completion_url/stream_url read only @model, and canonical_uri is a pure path + # transform, so allocate uninitialized instances to keep these tests focused and + # credential/connection-free. + let(:converse) { RubyLLM::Protocols::Converse.allocate } + let(:arn) { 'arn:aws:bedrock:us-west-2:123:application-inference-profile/p' } + + def with_model(id) + converse.instance_variable_set(:@model, instance_double(RubyLLM::Model::Info, id: id)) + end + + it 'keeps an application inference profile ARN as a single path segment in the converse URL' do + with_model(arn) + # The ARN's internal "/" is percent-encoded so it is not parsed as a path separator + # (which would truncate the modelId to ".../application-inference-profile"). + expect(converse.send(:completion_url)).to eq( + '/model/arn:aws:bedrock:us-west-2:123:application-inference-profile%2Fp/converse' + ) + end + + it 'encodes the ARN for the converse-stream URL too' do + with_model(arn) + expect(converse.send(:stream_url)).to eq( + '/model/arn:aws:bedrock:us-west-2:123:application-inference-profile%2Fp/converse-stream' + ) + end + + it 'leaves ordinary model ids (including a ":" version suffix) unchanged' do + with_model('us.anthropic.claude-sonnet-4-5-20250929-v1:0') + expect(converse.send(:completion_url)).to eq( + '/model/us.anthropic.claude-sonnet-4-5-20250929-v1:0/converse' + ) + end + + it 'signs the ARN as one segment (SigV4 canonical path double-encodes "/", not truncates)' do + with_model(arn) + path = URI.parse(converse.send(:completion_url)).path + # canonical_uri re-encodes each segment, turning the already-encoded "%2F" into + # "%252F" — so the profile id stays inside the modelId segment rather than becoming + # its own path segment, keeping the signed path consistent with the sent path. + expect(described_class.allocate.send(:canonical_uri, path)).to eq( + '/model/arn%3Aaws%3Abedrock%3Aus-west-2%3A123%3Aapplication-inference-profile%252Fp/converse' + ) + end + end end