diff --git a/lib/ruby_llm/protocols/converse/chat.rb b/lib/ruby_llm/protocols/converse/chat.rb index 71820188a..36fc87eaa 100644 --- a/lib/ruby_llm/protocols/converse/chat.rb +++ b/lib/ruby_llm/protocols/converse/chat.rb @@ -13,7 +13,13 @@ module Chat module_function def completion_url - "/model/#{@model.id}/converse" + "/model/#{escape_model_id(@model.id)}/converse" + end + + # Application inference profile ARNs contain '/', but Bedrock expects the model id + # to remain one path segment. + 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 56168c955..c54c5e990 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 995d26f26..91c45378b 100644 --- a/spec/ruby_llm/providers/bedrock_spec.rb +++ b/spec/ruby_llm/providers/bedrock_spec.rb @@ -160,4 +160,42 @@ def credential_provider(credentials = self.credentials) expect(provider.protocol_for(model)).to eq(RubyLLM::Protocols::Converse) end end + + describe 'model id path encoding' do + 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, id: id)) + end + + it 'keeps an application inference profile ARN as a single path segment in the converse URL' do + with_model(arn) + 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 + 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