Skip to content
Open
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
15 changes: 14 additions & 1 deletion lib/ruby_llm/protocols/converse/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>"). 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
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/protocols/converse/streaming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions spec/ruby_llm/providers/bedrock_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading