diff --git a/lib/ruby_llm/configuration.rb b/lib/ruby_llm/configuration.rb index f61e4fb28..52c1b65b4 100644 --- a/lib/ruby_llm/configuration.rb +++ b/lib/ruby_llm/configuration.rb @@ -70,11 +70,6 @@ def defaults = @defaults ||= {} option :log_stream_debug, -> { ENV['RUBYLLM_STREAM_DEBUG'] == 'true' } option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil } - # Auto-inject Bedrock InvokeModel prompt-cache breakpoints (system + tail). See - # RubyLLM::Protocols::BedrockInvokeModel::Chat#render_payload. Defaults on because the - # only production consumer of the InvokeModel path expects native caching to take over. - option :bedrock_invoke_model_prompt_caching, true - def initialize self.class.send(:defaults).each do |key, default| value = default.respond_to?(:call) ? instance_exec(&default) : default diff --git a/lib/ruby_llm/protocols/bedrock_invoke_model.rb b/lib/ruby_llm/protocols/bedrock_invoke_model.rb deleted file mode 100644 index 0980a4199..000000000 --- a/lib/ruby_llm/protocols/bedrock_invoke_model.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module RubyLLM - module Protocols - # Bedrock InvokeModel protocol — sends the raw Anthropic Messages format directly - # to the bedrock-runtime InvokeModel endpoint. This unlocks anthropic_beta features - # (e.g. context_management / server-side prompt caching) that are not available via - # the Converse API. Purely additive: Converse remains the default path. - class BedrockInvokeModel < Protocol - include BedrockInvokeModel::Chat - include BedrockInvokeModel::Streaming - - private - - def sync_response(payload, additional_headers = {}) - body = JSON.generate(payload) - response = @connection.post(completion_url, payload) do |req| - req.headers.merge!(@provider.sign_headers('POST', completion_url, body)) - req.headers.merge!(additional_headers) unless additional_headers.empty? - end - parse_completion_response(response) - end - end - end -end diff --git a/lib/ruby_llm/protocols/bedrock_invoke_model/chat.rb b/lib/ruby_llm/protocols/bedrock_invoke_model/chat.rb deleted file mode 100644 index 7f5c2d109..000000000 --- a/lib/ruby_llm/protocols/bedrock_invoke_model/chat.rb +++ /dev/null @@ -1,546 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -module RubyLLM - module Protocols - class BedrockInvokeModel - # Chat methods for the Bedrock InvokeModel API (raw Anthropic Messages format). - module Chat - BEDROCK_INLINE_DOCUMENT_LIMIT = 4_500_000 - - # Bedrock allows at most 4 cache_control breakpoints per request, counted across - # system, messages, and tools combined. - MAX_CACHE_BREAKPOINTS = 4 - - # Block types Bedrock will attach a cache breakpoint to. Thinking/redacted_thinking - # are intentionally excluded — Anthropic does not support caching on them. - CACHEABLE_BLOCK_TYPES = %w[text image tool_use tool_result document].freeze - - module_function - - def completion_url - "/model/#{escape_model_id(@model.id)}/invoke" - end - - # An application inference profile ARN contains "/" which must be percent-encoded - # so it forms a single URL path segment. See Converse::Chat#escape_model_id for the - # full explanation; the same logic applies to InvokeModel URLs. - def escape_model_id(model_id) - model_id.to_s.gsub('/', '%2F') - end - - def warn_unsupported_schema(model) - RubyLLM.logger.warn( - 'RubyLLM does not support structured output (schema:) on the BedrockInvokeModel path. ' \ - "Ignoring schema for #{model.id}." - ) - end - - def warn_unsupported_citations(model) - RubyLLM.logger.warn( - 'RubyLLM does not support citations on the BedrockInvokeModel path. ' \ - "Ignoring with_citations for #{model.id}." - ) - end - - def supports_provider_file_references? - true - end - - def default_large_file_upload_threshold - BEDROCK_INLINE_DOCUMENT_LIMIT - end - - def provider_file_attachable?(attachment) - attachment.pdf? || attachment.document? || attachment.text? - end - - # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument - def render_payload(messages, tools:, temperature:, model:, stream: false, - schema: nil, thinking: nil, citations: false, tool_prefs: nil) - warn_unsupported_schema(model) if schema - warn_unsupported_citations(model) if citations - tool_prefs ||= {} - system_messages, chat_messages = messages.partition { |msg| msg.role == :system } - - payload = { - anthropic_version: 'bedrock-2023-05-31', - max_tokens: model.max_tokens || 4096, - messages: format_messages(chat_messages) - } - - system_blocks = format_system(system_messages) - payload[:system] = system_blocks unless system_blocks.empty? - - payload[:temperature] = temperature unless temperature.nil? - - add_tool_fields(payload, tools, tool_prefs) - add_thinking_fields(payload, thinking) - add_beta_fields(payload) - inject_cache_breakpoints(payload) - - payload - end - # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument - - # Injects the two auto cache breakpoints (system tail + final-message tail) when - # bedrock_invoke_model_prompt_caching is enabled. Never removes a breakpoint that - # arrived via a translated cachePoint — only ever adds up to the 4-breakpoint budget. - # Deliberately does not estimate token counts against the model's minimum cacheable - # prefix (e.g. 4,096 tokens): a breakpoint below the minimum silently doesn't cache - # and costs nothing, so that estimation is left to the caller. - def inject_cache_breakpoints(payload) - return unless @config.bedrock_invoke_model_prompt_caching - - remaining = MAX_CACHE_BREAKPOINTS - count_cache_breakpoints(payload) - return if remaining <= 0 - - # Tail takes priority over system when only one breakpoint slot remains: the tail - # breakpoint is what makes the in-flight tool loop cheap round-trip to round-trip, - # while the system breakpoint only protects against a single oversized turn. - inject_system_cache_breakpoint?(payload[:system]) if remaining >= 2 - inject_tail_cache_breakpoint(payload[:messages]) - end - - def count_cache_breakpoints(payload) - count = 0 - count += count_blocks_with_cache_control(payload[:system]) - count += (payload[:tools] || []).count { |t| block_cache_control(t) } - (payload[:messages] || []).each { |m| count += count_blocks_with_cache_control(m[:content]) } - count - end - - def count_blocks_with_cache_control(blocks) - (blocks || []).count { |b| b.is_a?(Hash) && block_cache_control(b) } - end - - def block_cache_control(block) - block[:cache_control] || block['cache_control'] - end - - def block_type(block) - block[:type] || block['type'] - end - - def inject_system_cache_breakpoint?(system_blocks) - return false if system_blocks.nil? || system_blocks.empty? - return false if system_blocks.any? { |b| b.is_a?(Hash) && block_cache_control(b) } - - system_blocks.last[:cache_control] = { type: 'ephemeral' } - true - end - - def inject_tail_cache_breakpoint(messages) - return unless messages - - block = messages.reverse_each.lazy.filter_map { |m| last_cacheable_block(m[:content]) }.first - return unless block - return if block_cache_control(block) - - block[:cache_control] = { type: 'ephemeral' } - end - - def last_cacheable_block(blocks) - (blocks || []).reverse_each do |block| - return block if block.is_a?(Hash) && CACHEABLE_BLOCK_TYPES.include?(block_type(block)) - end - nil - end - - def add_tool_fields(payload, tools, tool_prefs) - return unless tools.any? - - payload[:tools] = tools.values.map { |tool| format_tool(tool) } - tool_choice = format_tool_choice(tool_prefs[:choice]) - payload[:tool_choice] = tool_choice if tool_choice - end - - def add_thinking_fields(payload, thinking) - fields = format_thinking_fields(thinking) - payload.merge!(fields) if fields - end - - def add_beta_fields(payload) - beta = @config.anthropic_beta - payload[:anthropic_beta] = Array(beta) if beta - - context_mgmt = @config.anthropic_context_management - payload[:context_management] = context_mgmt if context_mgmt - end - - def parse_completion_response(response) - parse_completion_body(response.body, raw: response) - end - - def parse_completion_body(data, raw:) - return if data.nil? || data.empty? - - content_blocks = data['content'] || [] - usage = data['usage'] || {} - - Message.new( - role: :assistant, - content: parse_text_content(content_blocks), - thinking: parse_thinking(content_blocks), - tool_calls: parse_tool_calls(content_blocks), - input_tokens: input_tokens(usage), - output_tokens: usage['output_tokens'], - cached_tokens: usage['cache_read_input_tokens'], - cache_creation_tokens: usage['cache_creation_input_tokens'], - finish_reason: data['stop_reason'], - model_id: data['model'], - raw: raw - ) - end - - def input_tokens(usage) - input = usage['input_tokens'] - return unless input - - [input.to_i - usage['cache_read_input_tokens'].to_i - usage['cache_creation_input_tokens'].to_i, 0].max - end - - def format_messages(messages) - rendered = [] - tool_result_blocks = [] - - messages.each do |msg| - if msg.tool_result? - tool_result_blocks << format_tool_result_block(msg) - next - end - - unless tool_result_blocks.empty? - append_message(rendered, { role: 'user', content: tool_result_blocks }) - tool_result_blocks = [] - end - - formatted = format_non_tool_message(msg) - append_message(rendered, formatted) if formatted - end - - append_message(rendered, { role: 'user', content: tool_result_blocks }) unless tool_result_blocks.empty? - rendered - end - - # Anthropic's Messages API (used via Bedrock InvokeModel) rejects a payload whose - # roles don't strictly alternate, just as Converse does. This happens whenever a - # synthesized tool-result message (always role: 'user') is immediately followed by a - # real user-authored message (e.g. one injected mid-tool-loop). Rather than emit a - # second message with the same role, fold its content blocks into the previous one. - def append_message(rendered, message) - previous = rendered.last - - if previous && previous[:role] == message[:role] - previous[:content] = merge_content_blocks(previous[:content], message[:content]) - else - rendered << message - end - end - - # Anthropic requires tool_result blocks to come first in a user message that also - # contains other content, and requires thinking/redacted_thinking blocks to come - # first in an assistant message. Merged content therefore orders thinking blocks - # first, then tool_result blocks, then every other block — each group in its - # original relative order. - def merge_content_blocks(existing_blocks, incoming_blocks) - combined = existing_blocks + incoming_blocks - thinking_blocks, rest = combined.partition { |block| THINKING_BLOCK_TYPES.include?(block_type(block)) } - tool_result_blocks, other_blocks = rest.partition { |block| block_type(block) == 'tool_result' } - thinking_blocks + tool_result_blocks + other_blocks - end - - def format_non_tool_message(msg) - content = format_message_content(msg) - return nil if content.empty? - - { role: format_role(msg.role), content: content } - end - - def format_message_content(msg) - if msg.content.is_a?(RubyLLM::Content::Raw) - raw = msg.content.value - return translate_raw_blocks(raw.is_a?(Array) ? raw : [raw]) - end - - blocks = [] - - if msg.role == :assistant - thinking_blocks = format_thinking_blocks(msg.thinking) - blocks.concat(thinking_blocks) if thinking_blocks - end - - blocks.concat(format_text_and_media(msg.content)) - - if msg.tool_call? - msg.tool_calls.each_value do |tool_call| - blocks << { type: 'tool_use', id: tool_call.id, name: tool_call.name, input: tool_call.arguments } - end - end - - blocks - end - - # Translates Content::Raw values into Anthropic Messages blocks at every point they - # enter this protocol. The consumer app persists system messages as Converse-format - # block arrays (e.g. [{ text: "..." }, { cachePoint: { type: 'default' } }]); those - # arrive here wrapped in Content::Raw with string keys after a DB round-trip or - # symbol keys in-memory. Anthropic-format blocks (already carrying a `type` key, - # including any cache_control) pass through unchanged. - def translate_raw_blocks(blocks) - result = [] - - blocks.each do |block| - translate_raw_block(block, result) - end - - result - end - - def translate_raw_block(block, result) - return result << block unless block.is_a?(Hash) - return result << block.dup if block.key?(:type) || block.key?('type') - - text = block[:text] || block['text'] - return result << { type: 'text', text: text } if text - - cache_point = block[:cachePoint] || block['cachePoint'] - return attach_cache_point(result, cache_point) if cache_point - - result << block - end - - # Replaces the previously emitted block (result.last) with a duped copy carrying - # cache_control, so the mutation never touches the caller's original hash object. - def attach_cache_point(result, cache_point) - block = result.last - return unless block - - ttl = cache_point[:ttl] || cache_point['ttl'] - cache_control = { type: 'ephemeral' } - cache_control[:ttl] = ttl if ttl - - result[-1] = block.dup.merge(cache_control: cache_control) - end - - def format_text_and_media(content) # rubocop:disable Metrics/PerceivedComplexity - return [] if content.nil? || (content.respond_to?(:empty?) && content.empty?) - - if content.is_a?(RubyLLM::Content::Raw) - raw = content.value - return translate_raw_blocks(raw.is_a?(Array) ? raw : [raw]) - end - - return [{ type: 'text', text: content.to_json }] if content.is_a?(Hash) || content.is_a?(Array) - return [{ type: 'text', text: content }] unless content.is_a?(RubyLLM::Content) - - blocks = [] - blocks << build_text_block(content.text) if content.text - content.attachments.each { |att| blocks << format_attachment(att) } - blocks - end - - def build_text_block(text) - { type: 'text', text: text } - end - - def format_attachment(attachment) - case attachment.type - when :image - format_image_attachment(attachment) - when :pdf, :document - format_document_attachment(attachment) - when :text - { type: 'text', text: attachment.for_llm } - else - raise UnsupportedAttachmentError, attachment.mime_type - end - end - - def format_image_attachment(attachment) - # Bedrock InvokeModel (Anthropic Messages format) does not support the `url` - # image source type — AWS rejects it. Only base64 is accepted. - if attachment.url? - raise UnsupportedAttachmentError, - 'Bedrock InvokeModel does not support URL image sources; ' \ - "provide a local file or IO instead. (attachment: #{attachment.source})" - end - - { - type: 'image', - source: { type: 'base64', media_type: attachment.mime_type, data: attachment.encoded } - } - end - - def format_document_attachment(attachment) - { - type: 'document', - source: { type: 'base64', media_type: attachment.mime_type, data: attachment.encoded } - } - end - - def format_tool_result_block(msg) - { - type: 'tool_result', - tool_use_id: msg.tool_call_id, - content: format_tool_result_content(msg.content) - } - end - - def format_tool_result_content(content) - if content.is_a?(RubyLLM::Content::Raw) - raw = content.value - return translate_raw_blocks(raw.is_a?(Array) ? raw : [raw]) - end - return [{ type: 'text', text: content.to_json }] if content.is_a?(Hash) || content.is_a?(Array) - return content_to_blocks_or_fallback(content) if content.is_a?(RubyLLM::Content) - - text = content.to_s - text = '(no output)' if text.empty? - [{ type: 'text', text: text }] - end - - def content_to_blocks_or_fallback(content) - blocks = [] - blocks << { type: 'text', text: content.text } unless content.text.to_s.empty? - content.attachments.each { |att| blocks << format_attachment(att) } - blocks.empty? ? [{ type: 'text', text: '(no output)' }] : blocks - end - - def format_role(role) - case role - when :assistant then 'assistant' - else 'user' - end - end - - def format_system(messages) - messages.flat_map { |msg| format_text_and_media(msg.content) } - end - - def format_tool(tool) - input_schema = tool.params_schema || - RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema - - declaration = { - name: tool.name, - description: tool.description, - input_schema: input_schema || default_input_schema - } - - return declaration if tool.provider_params.empty? - - RubyLLM::Utils.deep_merge(declaration, tool.provider_params) - end - - def format_tool_choice(choice) - case choice - when :auto then { type: 'auto' } - when :required then { type: 'any' } - when nil, :none then nil - else { type: 'tool', name: choice.to_s } - end - end - - def format_thinking_fields(thinking) - return nil unless thinking&.enabled? - - budget = thinking.budget - if budget.is_a?(Integer) - # An explicit integer budget is a deliberate manual-budget opt-in and must use - # type: 'enabled'. Only meaningful on models that still accept budget_tokens. - { thinking: { type: 'enabled', budget_tokens: budget } } - else - effort = thinking.effort.to_s - return nil if effort.empty? || effort == 'none' - - # Manual thinking: { type: 'enabled' } returns a 400 on current Anthropic models - # (Sonnet 5, Opus 4.7/4.8, Fable 5, ...). Effort-based extended thinking must use - # adaptive thinking with the effort carried in output_config. - { thinking: { type: 'adaptive' }, output_config: { effort: effort } } - end - end - - # Anthropic requires every thinking/redacted_thinking block from the most recent - # assistant turn to be replayed verbatim. When thinking.blocks is present, it holds - # the exact original blocks and must be replayed in order; text/signature are kept - # only for display purposes. - def format_thinking_blocks(thinking) - return nil unless thinking - - return thinking.blocks if thinking.blocks - - block = format_single_thinking_block(thinking) - block ? [block] : nil - end - - # A text-only block with no signature (e.g. a turn truncated mid-thinking) is dropped: - # Anthropic rejects replayed thinking blocks without a valid signature, so a - # signature-less reconstruction is worse than omitting the block entirely. This mirrors - # the streaming path's drop-on-truncation behavior (see streaming.rb). - def format_single_thinking_block(thinking) - if thinking.text && thinking.signature - { type: 'thinking', thinking: thinking.text, signature: thinking.signature } - elsif thinking.signature - { type: 'redacted_thinking', data: thinking.signature } - end - end - - def parse_text_content(content_blocks) - text = content_blocks.filter_map do |block| - block['text'] if block['type'] == 'text' && block['text'].is_a?(String) - end.join - text.empty? ? nil : text - end - - THINKING_BLOCK_TYPES = %w[thinking redacted_thinking].freeze - private_constant :THINKING_BLOCK_TYPES - - # Returns a Thinking instance whose blocks field is the source of truth for replay; - # text/signature are merged across all blocks and kept only for display purposes. - def parse_thinking(content_blocks) - raw_blocks = content_blocks.select { |b| THINKING_BLOCK_TYPES.include?(b['type']) } - return nil if raw_blocks.empty? - - text, signature = merge_thinking_blocks(raw_blocks) - Thinking.build(text: text, signature: signature, blocks: raw_blocks) - end - - def merge_thinking_blocks(raw_blocks) - text = +'' - signature = nil - raw_blocks.each do |b| - text << b['thinking'] if b['thinking'].is_a?(String) - signature ||= b['signature'] || b['data'] - end - [text.empty? ? nil : text, signature] - end - - def parse_tool_calls(content_blocks) - tool_calls = {} - - content_blocks.each do |block| - next unless block['type'] == 'tool_use' - - tool_calls[block['id']] = ToolCall.new( - id: block['id'], - name: block['name'], - arguments: block['input'] || {} - ) - end - - tool_calls.empty? ? nil : tool_calls - end - - def default_input_schema - { - 'type' => 'object', - 'properties' => {}, - 'required' => [] - } - end - end - end - end -end diff --git a/lib/ruby_llm/protocols/bedrock_invoke_model/streaming.rb b/lib/ruby_llm/protocols/bedrock_invoke_model/streaming.rb deleted file mode 100644 index 62b72bd8b..000000000 --- a/lib/ruby_llm/protocols/bedrock_invoke_model/streaming.rb +++ /dev/null @@ -1,297 +0,0 @@ -# frozen_string_literal: true - -require 'base64' -require 'faraday' -require 'json' - -module RubyLLM - module Protocols - class BedrockInvokeModel - # Streaming implementation for Bedrock InvokeModel with response stream - # (AWS Event Stream). The event-stream byte decode below is duplicated from - # Converse::Streaming intentionally — keeping the two paths byte-for-byte - # independent ensures Converse patches never fire on the InvokeModel path. - module Streaming - ErrorResponse = Struct.new(:body, :status) - - private - - def stream_url - "/model/#{escape_model_id(@model.id)}/invoke-with-response-stream" - end - - def stream_response(payload, additional_headers = {}, &block) - accumulator = StreamAccumulator.new - decoder = event_stream_decoder - thinking_state = {} - body = JSON.generate(payload) - - response = @connection.post(stream_url, payload) do |req| - req.headers.merge!(@provider.sign_headers('POST', stream_url, body)) - req.headers.merge!(additional_headers) unless additional_headers.empty? - req.headers['Accept'] = 'application/vnd.amazon.eventstream' - - if Faraday::VERSION.start_with?('1') - req.options[:on_data] = proc do |chunk, _size| - parse_stream_chunk(decoder, chunk, accumulator, thinking_state, &block) - end - else - req.options.on_data = proc do |chunk, _bytes, env| - if env&.status == 200 - parse_stream_chunk(decoder, chunk, accumulator, thinking_state, &block) - else - handle_failed_stream(chunk, env) - end - end - end - end - - message = accumulator.to_message(response) - RubyLLM.logger.debug { "Stream completed: #{message.content}" } - message - end - - def event_stream_decoder - require 'aws-eventstream' - Aws::EventStream::Decoder.new - rescue LoadError - raise Error, - 'The aws-eventstream gem is required for Bedrock streaming. ' \ - 'Please add it to your Gemfile: gem "aws-eventstream"' - end - - def handle_failed_stream(chunk, env) - data = JSON.parse(chunk) - error_response = env.merge(body: data) - ErrorMiddleware.parse_error(provider: self, response: error_response) - rescue JSON::ParserError - RubyLLM.logger.debug { "Failed Bedrock stream error chunk: #{chunk}" } - end - - def parse_stream_chunk(decoder, raw_chunk, accumulator, thinking_state) - handle_non_eventstream_error_chunk(raw_chunk) - - decode_events(decoder, raw_chunk).each do |event| - chunk = build_chunk(event, thinking_state) - next unless chunk - - accumulator.add(chunk) - yield chunk - end - end - - def handle_non_eventstream_error_chunk(raw_chunk) - text = raw_chunk.to_s - - if text.start_with?('event: error') - payload = text.lines.find { |line| line.start_with?('data:') }&.delete_prefix('data:')&.strip - raise_streaming_chunk_error(payload) if payload - return - end - - return unless text.lstrip.start_with?('{') && text.include?('"error"') - - raise_streaming_chunk_error(text) - end - - def raise_streaming_chunk_error(payload) - parsed = JSON.parse(payload) - message = parsed.dig('error', 'message') || parsed['message'] || 'Bedrock streaming error' - response = ErrorResponse.new({ 'message' => message }, 500) - ErrorMiddleware.parse_error(provider: self, response: response) - rescue JSON::ParserError - nil - end - - # re-verify on gem bump: aws-eventstream Decoder#decode_chunk API - def decode_events(decoder, raw_chunk) - events = [] - message, eof = decoder.decode_chunk(raw_chunk) - - while message - event = decode_event_payload(message.payload.read) - if event && RubyLLM.config.log_stream_debug - RubyLLM.logger.debug do - "Bedrock InvokeModel stream event keys: #{event.keys}" - end - end - events << event if event - break if eof - - message, eof = decoder.decode_chunk - end - - events - end - - def decode_event_payload(payload) - outer = JSON.parse(payload) - - if outer['bytes'].is_a?(String) - JSON.parse(Base64.decode64(outer['bytes'])) - else - outer - end - rescue JSON::ParserError => e - RubyLLM.logger.debug { "Failed to decode Bedrock InvokeModel stream event payload: #{e.message}" } - nil - end - - def build_chunk(event, thinking_state = {}) - raise_stream_error(event) if stream_error_event?(event) - - type = event['type'] - - case type - when 'message_start' - build_message_start_chunk(event) - when 'content_block_start' - build_content_block_start_chunk(event, thinking_state) - when 'content_block_delta' - build_content_block_delta_chunk(event, thinking_state) - when 'content_block_stop' - build_content_block_stop_chunk(event, thinking_state) - when 'message_delta' - build_message_delta_chunk(event) - else - Chunk.new(role: :assistant, content: nil, model_id: @model&.id) - end - end - - def build_message_start_chunk(event) - message = event['message'] || {} - usage = message['usage'] || {} - - Chunk.new( - role: :assistant, - content: nil, - model_id: message['model'] || @model&.id, - input_tokens: input_tokens(usage), - cached_tokens: usage['cache_read_input_tokens'], - cache_creation_tokens: usage['cache_creation_input_tokens'] - ) - end - - def build_content_block_start_chunk(event, thinking_state) - content_block = event['content_block'] || {} - index = event['index'] - tool_calls = nil - thinking = nil - - case content_block['type'] - when 'tool_use' - id = content_block['id'] - tool_calls = { - id => ToolCall.new(id: id, name: content_block['name'], arguments: {}) - } - when 'redacted_thinking' - thinking = Thinking.build(blocks: [{ 'type' => 'redacted_thinking', 'data' => content_block['data'] }]) - when 'thinking' - thinking_state[index] = { text: +'', signature: nil } - end - - Chunk.new( - role: :assistant, - content: nil, - model_id: @model&.id, - thinking: thinking, - tool_calls: tool_calls - ) - end - - def build_content_block_delta_chunk(event, thinking_state) - delta = event['delta'] || {} - delta_type = delta['type'] - index = event['index'] - - content = nil - thinking_text = nil - thinking_sig = nil - tool_calls = nil - - case delta_type - when 'text_delta' - content = delta['text'] - when 'input_json_delta' - partial = delta['partial_json'] - tool_calls = { nil => ToolCall.new(id: nil, name: nil, arguments: partial) } if partial - when 'thinking_delta' - thinking_text = delta['thinking'] - thinking_state[index][:text] << thinking_text.to_s if thinking_state[index] - when 'signature_delta' - thinking_sig = delta['signature'] - thinking_state[index][:signature] = thinking_sig if thinking_state[index] - end - - Chunk.new( - role: :assistant, - model_id: @model&.id, - content: content, - thinking: Thinking.build(text: thinking_text, signature: thinking_sig), - tool_calls: tool_calls - ) - end - - # A thinking block only finalizes here, on its content_block_stop. If the turn - # is truncated (e.g. stop_reason 'max_tokens') before this event arrives for a - # given index, that block is intentionally dropped rather than replayed - # signature-less — Anthropic rejects replay of a thinking block without a valid - # signature, so a half-formed block is worse than none on the next request. - def build_content_block_stop_chunk(event, thinking_state) - index = event['index'] - state = thinking_state.delete(index) - return Chunk.new(role: :assistant, content: nil, model_id: @model&.id) unless state - - block = { 'type' => 'thinking', 'thinking' => state[:text], 'signature' => state[:signature] } - - Chunk.new( - role: :assistant, - content: nil, - model_id: @model&.id, - thinking: Thinking.build(blocks: [block]) - ) - end - - def build_message_delta_chunk(event) - delta = event['delta'] || {} - usage = event['usage'] || {} - - Chunk.new( - role: :assistant, - content: nil, - model_id: @model&.id, - output_tokens: usage['output_tokens'], - finish_reason: delta['stop_reason'] - ) - end - - def stream_error_event?(event) - event.keys.any? { |key| key.end_with?('Exception') } || event['type'] == 'error' - end - - def raise_stream_error(event) - if event['type'] == 'error' - message = event.dig('error', 'message') || 'Bedrock streaming error' - response = ErrorResponse.new({ 'message' => message }, 500) - ErrorMiddleware.parse_error(provider: self, response: response) - return - end - - key = event.keys.find { |candidate| candidate.end_with?('Exception') } - payload = event[key] - message = payload['message'] || key - status = case key - when 'throttlingException' then 429 - when 'validationException' then 400 - when 'accessDeniedException', 'unrecognizedClientException' then 401 - when 'serviceUnavailableException' then 503 - else 500 - end - - response = ErrorResponse.new({ 'message' => message }, status) - ErrorMiddleware.parse_error(provider: self, response: response) - end - end - end - end -end diff --git a/lib/ruby_llm/protocols/mantle_responses.rb b/lib/ruby_llm/protocols/mantle_responses.rb index f72eb6073..b12efc704 100644 --- a/lib/ruby_llm/protocols/mantle_responses.rb +++ b/lib/ruby_llm/protocols/mantle_responses.rb @@ -4,7 +4,7 @@ module RubyLLM module Protocols # AWS Bedrock's bedrock-mantle endpoint, speaking the OpenAI Responses API. # Reachable only via bedrock-mantle (not bedrock-runtime), and only for models - # that physically cannot serve Converse or InvokeModel (the GPT-5.x frontier + # that physically cannot serve Converse (the GPT-5.x frontier # family). Talks to the provider's mantle connection instead of the default # bedrock-runtime connection, and SigV4-signs every request against the # "bedrock-mantle" service namespace instead of "bedrock". @@ -14,7 +14,7 @@ class MantleResponses < Responses # AWS Bedrock model cards for GPT-5.6. # # Kept in sync with Providers::Bedrock::MANTLE_ONLY_MODEL_PATTERN — that pattern picks - # mantle vs Converse/InvokeModel, this one picks the /openai/v1 vs /v1 mantle path. They + # mantle vs Converse, this one picks the /openai/v1 vs /v1 mantle path. They # coincide today but are distinct concepts; update both when a new frontier family lands. FRONTIER_GPT5_PATTERN = /\Aopenai\.gpt-5/ diff --git a/lib/ruby_llm/providers/bedrock.rb b/lib/ruby_llm/providers/bedrock.rb index 9e9161f32..3b7aa9049 100644 --- a/lib/ruby_llm/providers/bedrock.rb +++ b/lib/ruby_llm/providers/bedrock.rb @@ -8,7 +8,6 @@ class Bedrock < Provider include Bedrock::Models protocol :converse, Protocols::Converse, batches: Protocols::Converse::Batches - protocol :bedrock_invoke_model, Protocols::BedrockInvokeModel protocol :mantle_responses, Protocols::MantleResponses files Bedrock::Files @@ -18,7 +17,7 @@ class Bedrock < Provider # one-line change if this assumption turns out to be wrong. MANTLE_SIGNING_SERVICE = 'bedrock-mantle' - # openai.gpt-5.x ids are only reachable on bedrock-mantle (no Converse, no InvokeModel). + # openai.gpt-5.x ids are only reachable on bedrock-mantle, not Converse. # Deliberately narrower than /\Aopenai\./ — openai.gpt-oss-* models ARE served by # bedrock-runtime and must keep routing to Converse. MANTLE_ONLY_MODEL_PATTERN = /\Aopenai\.gpt-5/ @@ -59,7 +58,7 @@ def complete(messages, model:, params: {}, **rest, &) def protocol_for(model, **) return fetch_protocol(:mantle_responses) if mantle_only_model?(model) - invoke_model?(model) ? fetch_protocol(:bedrock_invoke_model) : fetch_protocol(:converse) + fetch_protocol(:converse) end def parse_error(response) @@ -89,10 +88,6 @@ def configuration_options bedrock_mantle_region bedrock_batch_s3_uri bedrock_batch_role_arn - bedrock_use_invoke_model - anthropic_beta - anthropic_context_management - bedrock_invoke_model_prompt_caching ] end @@ -140,11 +135,10 @@ def bedrock_region @config.bedrock_region end - # openai.gpt-5.x ids cannot serve Converse or InvokeModel; routing here is automatic and - # needs no config knob (unlike bedrock_use_invoke_model, which is an optimization choice). + # openai.gpt-5.x ids cannot serve Converse; routing here is automatic. # # Kept in sync with Protocols::MantleResponses::FRONTIER_GPT5_PATTERN — that pattern picks - # the /openai/v1 vs /v1 mantle path, this one picks mantle vs Converse/InvokeModel. They + # the /openai/v1 vs /v1 mantle path, this one picks mantle vs Converse. They # coincide today but are distinct concepts; update both when a new frontier family lands. def mantle_only_model?(model) MANTLE_ONLY_MODEL_PATTERN.match?(model.id.to_s) @@ -186,96 +180,6 @@ def strip_converse_only_params(params) "#{offending.join(', ')} are Converse-only params and are not supported on " \ 'bedrock-mantle (Responses API)' end - - # Returns true if the InvokeModel protocol should be used for this model. - # `bedrock_use_invoke_model` can be: - # - false / nil → always Converse (default) - # - true → InvokeModel for all verifiably Anthropic models - # - Array → InvokeModel when model.id is in the list - # - Proc/lambda → InvokeModel when the callable returns truthy for model - # - # Vendor verification interacts with the selector in two tiers: - # - Ids that are provably non-Anthropic (a known vendor prefix like amazon./meta., - # with or without a cross-region geo prefix) are never routed, under any selector — - # the InvokeModel payload is Anthropic Messages format and would be rejected. - # - Ids that cannot be verified either way — chiefly application-inference-profile - # ARNs, whose Model::Info carries no provider_name metadata on the - # assume_model_exists path — are routed when the selector opts in explicitly - # (Array or Proc). An operator naming the exact id IS the verification. Only the - # blanket `true` selector requires positive verification via anthropic_model?, - # because it expresses "all Anthropic models", not "this specific model". - def invoke_model?(model) - selector = @config.bedrock_use_invoke_model - return false unless selector - return false if non_anthropic_model?(model) - - case selector - when true - anthropic_model?(model) - when Array - selector.include?(model.id) - else - selector.respond_to?(:call) ? selector.call(model) : false - end - end - - NON_ANTHROPIC_VENDORS = %w[amazon meta ai21 cohere mistral writer stability].freeze - private_constant :NON_ANTHROPIC_VENDORS - - # Matches known non-Anthropic vendor ids in both bare ("amazon.nova-pro-v1:0") and - # cross-region ("us.amazon.nova-pro-v1:0") forms. - NON_ANTHROPIC_PATTERN = /\A(?:[a-z0-9-]+\.)?(?:#{NON_ANTHROPIC_VENDORS.join('|')})\./ - private_constant :NON_ANTHROPIC_PATTERN - - # True only when the id provably belongs to a non-Anthropic vendor. ARNs return false: - # they don't encode the vendor, so they are "unverifiable", not "non-Anthropic". - def non_anthropic_model?(model) - id = model.id.to_s - return false if id.start_with?('arn:') - - NON_ANTHROPIC_PATTERN.match?(id) - end - - def anthropic_model?(model) - id = model.id.to_s - # Standard Anthropic model ids start with "anthropic." - return true if id.start_with?('anthropic.') - - # Cross-region inference profile ids prefix the vendor with a geo code - # (us., eu., apac., global., jp., au., us-gov., ...): "us.anthropic.claude-sonnet-5". - # Match any geo prefix followed by "anthropic." rather than enumerating regions AWS - # may add. Cannot false-positive on other vendors' cross-region profiles - # (e.g. "us.amazon.nova-pro-v1:0") since those don't contain "anthropic.". - return true if id.match?(/\A[a-z0-9-]+\.anthropic\./) - - # Application-inference-profile ARNs do not encode the underlying vendor in the ARN - # string itself. When available, consult model.metadata[:provider_name] (populated - # by Bedrock::Models for registered foundation models) to confirm the profile is - # Anthropic-backed. If that field is absent (e.g. an assume_model_exists chat, whose - # Model::Info carries no registry metadata), log a warning and refuse to route rather - # than silently forwarding an incompatible payload to a non-Anthropic model — the - # operator can still opt in explicitly via an Array or Proc selector (see - # invoke_model?). - return arn_anthropic_model?(model, id) if id.start_with?('arn:') - - # Block known non-Anthropic Bedrock vendor prefixes (Nova, Llama, Jurassic, etc.). - return false if NON_ANTHROPIC_PATTERN.match?(id) - - provider = model.respond_to?(:provider) ? model.provider : nil - provider.to_s == 'anthropic' - end - - def arn_anthropic_model?(model, id) - provider_name = model.respond_to?(:metadata) ? model.metadata&.fetch(:provider_name, nil) : nil - return provider_name.to_s.downcase == 'anthropic' if provider_name - - RubyLLM.logger.warn( - "RubyLLM cannot verify that ARN model id #{id.inspect} is Anthropic-backed " \ - '(no provider_name in model.metadata). Refusing to route to BedrockInvokeModel. ' \ - 'Use an Array or Proc selector with bedrock_use_invoke_model to opt in explicitly.' - ) - false - end end end end diff --git a/spec/ruby_llm/protocols/bedrock_invoke_model_spec.rb b/spec/ruby_llm/protocols/bedrock_invoke_model_spec.rb deleted file mode 100644 index 02f5be6c3..000000000 --- a/spec/ruby_llm/protocols/bedrock_invoke_model_spec.rb +++ /dev/null @@ -1,1301 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe RubyLLM::Protocols::BedrockInvokeModel do - # --------------------------------------------------------------------------- - # Helpers - # --------------------------------------------------------------------------- - - def build_model(id, max_tokens: 4096) - instance_double( - RubyLLM::Model::Info, - id: id, - max_tokens: max_tokens, - metadata: {} - ) - end - - def build_config(overrides = {}) - cfg = RubyLLM::Configuration.new - cfg.bedrock_api_key = 'test-key' - cfg.bedrock_secret_key = 'test-secret' - cfg.bedrock_region = 'us-east-1' - overrides.each { |k, v| cfg.public_send(:"#{k}=", v) } - cfg - end - - # Returns a BedrockInvokeModel instance with @model and @config set. - def make_instance(model_id: 'anthropic.claude-haiku-4-5-20251001-v1:0', - max_tokens: 4096, - config_overrides: {}) - config = build_config(config_overrides) - model = build_model(model_id, max_tokens: max_tokens) - described_class.allocate.tap do |obj| - obj.instance_variable_set(:@model, model) - obj.instance_variable_set(:@config, config) - end - end - - def render_payload(messages = [], **opts) - model_id = opts.fetch(:model_id, 'anthropic.claude-haiku-4-5-20251001-v1:0') - max_tokens = opts.fetch(:max_tokens, 4096) - tools = opts.fetch(:tools, {}) - temperature = opts.fetch(:temperature, nil) - thinking = opts.fetch(:thinking, nil) - config_overrides = opts.fetch(:config_overrides, {}) - - inst = make_instance(model_id: model_id, max_tokens: max_tokens, config_overrides: config_overrides) - model = inst.instance_variable_get(:@model) - inst.send(:render_payload, messages, - tools: tools, temperature: temperature, model: model, thinking: thinking) - end - - # --------------------------------------------------------------------------- - # escape_model_id - # --------------------------------------------------------------------------- - - describe 'Chat#escape_model_id' do - subject(:chat) { described_class::Chat } - - it 'leaves a plain model id unchanged' do - expect(chat.escape_model_id('anthropic.claude-haiku-4-5-20251001-v1:0')) - .to eq('anthropic.claude-haiku-4-5-20251001-v1:0') - end - - it 'percent-encodes slashes in application-inference-profile ARNs' do - arn = 'arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123' - encoded = chat.escape_model_id(arn) - expect(encoded).not_to include('/') - expect(encoded).to include('%2F') - end - - it 'generates /invoke URL with encoded ARN' do - arn = 'arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123' - inst = make_instance(model_id: arn) - url = inst.send(:completion_url) - expect(url).to start_with('/model/') - expect(url).to end_with('/invoke') - expect(url).not_to include('application-inference-profile/') - end - - it 'generates /invoke-with-response-stream URL with encoded ARN' do - arn = 'arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123' - inst = make_instance(model_id: arn) - url = inst.send(:stream_url) - expect(url).to end_with('/invoke-with-response-stream') - expect(url).not_to include('application-inference-profile/') - end - end - - # --------------------------------------------------------------------------- - # render_payload - # --------------------------------------------------------------------------- - - describe 'Chat#render_payload' do - it 'includes anthropic_version' do - expect(render_payload[:anthropic_version]).to eq('bedrock-2023-05-31') - end - - it 'places max_tokens at the top level' do - expect(render_payload(max_tokens: 8192)[:max_tokens]).to eq(8192) - end - - it 'does NOT include a model field in the body' do - expect(render_payload).not_to have_key(:model) - expect(render_payload).not_to have_key('model') - end - - it 'defaults max_tokens to 4096 when model.max_tokens is nil' do - expect(render_payload(max_tokens: nil)[:max_tokens]).to eq(4096) - end - - it 'includes temperature when provided' do - expect(render_payload(temperature: 0.7)[:temperature]).to eq(0.7) - end - - it 'omits temperature when nil' do - expect(render_payload(temperature: nil)).not_to have_key(:temperature) - end - - it 'formats messages in Anthropic shape (text blocks)' do - msg = RubyLLM::Message.new(role: :user, content: 'Hello') - result = render_payload([msg]) - expect(result[:messages].first[:role]).to eq('user') - expect(result[:messages].first[:content].first[:type]).to eq('text') - expect(result[:messages].first[:content].first[:text]).to eq('Hello') - end - - it 'formats system content as top-level :system array' do - sys = RubyLLM::Message.new(role: :system, content: 'You are helpful') - result = render_payload([sys]) - expect(result[:system]).to be_an(Array) - expect(result[:system].first[:type]).to eq('text') - expect(result[:system].first[:text]).to eq('You are helpful') - expect(result[:messages]).to be_empty - end - - it 'formats tool definitions in Anthropic shape (input_schema)' do - tool = instance_double( - RubyLLM::Tool, - name: 'my_tool', - description: 'does stuff', - parameters: {}, - params_schema: { 'type' => 'object', 'properties' => {}, 'required' => [] }, - provider_params: {} - ) - result = render_payload(tools: { 'my_tool' => tool }) - expect(result[:tools]).not_to be_nil - expect(result[:tools].first[:name]).to eq('my_tool') - expect(result[:tools].first[:input_schema]).to be_a(Hash) - end - - it 'uses Converse-style toolSpec shape is NOT present (Anthropic native shape)' do - tool = instance_double( - RubyLLM::Tool, - name: 'my_tool', - description: 'does stuff', - parameters: {}, - params_schema: nil, - provider_params: {} - ) - result = render_payload(tools: { 'my_tool' => tool }) - expect(result[:tools].first).not_to have_key(:toolSpec) - end - - it 'includes anthropic_beta array when configured' do - result = render_payload(config_overrides: { anthropic_beta: ['interleaved-thinking-2025-05-14'] }) - expect(result[:anthropic_beta]).to eq(['interleaved-thinking-2025-05-14']) - end - - it 'wraps a scalar anthropic_beta in an array' do - result = render_payload(config_overrides: { anthropic_beta: 'prompt-caching-2024-07-31' }) - expect(result[:anthropic_beta]).to eq(['prompt-caching-2024-07-31']) - end - - it 'includes context_management when configured' do - result = render_payload(config_overrides: { anthropic_context_management: { type: 'auto' } }) - expect(result[:context_management]).to eq({ type: 'auto' }) - end - - it 'omits anthropic_beta when not configured' do - result = render_payload - expect(result).not_to have_key(:anthropic_beta) - end - - it 'omits context_management when not configured' do - result = render_payload - expect(result).not_to have_key(:context_management) - end - end - - # --------------------------------------------------------------------------- - # format_messages - # --------------------------------------------------------------------------- - - describe 'Chat#format_messages' do - subject(:chat) { described_class::Chat } - - it 'formats text content as {type: text, text: ...} blocks' do - msg = RubyLLM::Message.new(role: :user, content: 'hi') - result = chat.format_messages([msg]) - expect(result.first[:content].first).to eq({ type: 'text', text: 'hi' }) - end - - it 'formats tool_use blocks in Anthropic shape' do - tool_call = RubyLLM::ToolCall.new(id: 'call_1', name: 'my_tool', arguments: { x: 1 }) - msg = RubyLLM::Message.new(role: :assistant, content: '', tool_calls: { 'call_1' => tool_call }) - result = chat.format_messages([msg]) - block = result.first[:content].find { |b| b[:type] == 'tool_use' } - expect(block[:type]).to eq('tool_use') - expect(block[:id]).to eq('call_1') - expect(block[:name]).to eq('my_tool') - expect(block[:input]).to eq({ x: 1 }) - end - - it 'formats tool_result blocks in Anthropic shape' do - msg = RubyLLM::Message.new(role: :tool, content: 'result text', tool_call_id: 'call_1') - result = chat.format_messages([msg]) - content = result.first[:content] - block = content.find { |b| b.is_a?(Hash) && b[:type] == 'tool_result' } - expect(block[:type]).to eq('tool_result') - expect(block[:tool_use_id]).to eq('call_1') - end - - it 'formats thinking blocks in Anthropic shape when role is assistant' do - thinking = RubyLLM::Thinking.build(text: 'my thought', signature: 'sig') - msg = RubyLLM::Message.new(role: :assistant, content: 'reply', thinking: thinking) - result = chat.format_messages([msg]) - thinking_block = result.first[:content].find { |b| b[:type] == 'thinking' } - expect(thinking_block[:type]).to eq('thinking') - expect(thinking_block[:thinking]).to eq('my thought') - expect(thinking_block[:signature]).to eq('sig') - end - - it 'drops a text-only thinking block with no signature rather than replaying it' do - thinking = RubyLLM::Thinking.build(text: 'cut off mid-thought', signature: nil) - msg = RubyLLM::Message.new(role: :assistant, content: 'reply', thinking: thinking) - result = chat.format_messages([msg]) - thinking_block = result.first[:content].find { |b| b.is_a?(Hash) && b[:type] == 'thinking' } - expect(thinking_block).to be_nil - end - - it 'preserves cache_control when block already carries one' do - raw_block = { 'type' => 'text', 'text' => 'cached', 'cache_control' => { 'type' => 'ephemeral' } } - raw_content = RubyLLM::Content::Raw.new([raw_block]) - msg = RubyLLM::Message.new(role: :user, content: raw_content) - result = chat.format_messages([msg]) - expect(result.first[:content].first['cache_control']).to eq({ 'type' => 'ephemeral' }) - end - - it 'does not inject cache_control when block has none' do - msg = RubyLLM::Message.new(role: :user, content: 'plain text') - result = chat.format_messages([msg]) - block = result.first[:content].first - expect(block).not_to have_key(:cache_control) - expect(block).not_to have_key('cache_control') - end - - context 'when consecutive messages resolve to the same role' do - # Anthropic's Messages API (used via Bedrock InvokeModel) also rejects a payload whose - # roles don't strictly alternate. This happens when a new user message is injected - # mid-tool-loop immediately after a tool result, since tool results are synthesized as - # role: 'user'. - it 'merges multiple consecutive user messages into one' do - messages = [ - RubyLLM::Message.new(role: :user, content: 'first'), - RubyLLM::Message.new(role: :user, content: 'second') - ] - - result = chat.format_messages(messages) - - expect(result.size).to eq(1) - expect(result.first[:role]).to eq('user') - expect(result.first[:content]).to eq([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }]) - end - - it 'merges a user message that immediately follows a tool-result-flushed user message' do - messages = [ - RubyLLM::Message.new(role: :assistant, content: 'thinking', tool_calls: { - 't1' => RubyLLM::ToolCall.new(id: 't1', name: 'search', arguments: {}) - }), - RubyLLM::Message.new(role: :user, content: 'result', tool_call_id: 't1'), - RubyLLM::Message.new(role: :user, content: 'injected mid-loop message') - ] - - result = chat.format_messages(messages) - - expect(result.size).to eq(2) - merged = result.last - expect(merged[:role]).to eq('user') - expect(merged[:content]).to eq([ - { type: 'tool_result', tool_use_id: 't1', - content: [{ type: 'text', text: 'result' }] }, - { type: 'text', text: 'injected mid-loop message' } - ]) - end - - it 'merges consecutive assistant messages into one' do - messages = [ - RubyLLM::Message.new(role: :assistant, content: 'first'), - RubyLLM::Message.new(role: :assistant, content: 'second') - ] - - result = chat.format_messages(messages) - - expect(result.size).to eq(1) - expect(result.first[:role]).to eq('assistant') - expect(result.first[:content]).to eq([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }]) - end - - it 'keeps tool_result blocks ahead of other content blocks regardless of merge order' do - messages = [ - RubyLLM::Message.new(role: :assistant, content: 'thinking', tool_calls: { - 't1' => RubyLLM::ToolCall.new(id: 't1', name: 'search', arguments: {}) - }), - RubyLLM::Message.new(role: :user, content: 'injected before result', tool_call_id: nil), - RubyLLM::Message.new(role: :user, content: 'result', tool_call_id: 't1') - ] - - result = chat.format_messages(messages) - - expect(result.size).to eq(2) - merged = result.last - expect(merged[:role]).to eq('user') - expect(merged[:content]).to eq([ - { type: 'tool_result', tool_use_id: 't1', - content: [{ type: 'text', text: 'result' }] }, - { type: 'text', text: 'injected before result' } - ]) - end - - it 'hoists thinking blocks ahead of tool_result and other blocks when merging assistant messages' do - thinking = RubyLLM::Thinking.build(text: 'second thought', signature: 'sig-2') - messages = [ - RubyLLM::Message.new(role: :assistant, content: 'first thought'), - RubyLLM::Message.new(role: :assistant, content: 'second thought', thinking: thinking) - ] - - result = chat.format_messages(messages) - - expect(result.size).to eq(1) - merged = result.first - expect(merged[:role]).to eq('assistant') - expect(merged[:content]).to eq([ - { type: 'thinking', thinking: 'second thought', signature: 'sig-2' }, - { type: 'text', text: 'first thought' }, - { type: 'text', text: 'second thought' } - ]) - end - end - end - - # --------------------------------------------------------------------------- - # parse_completion_body - # --------------------------------------------------------------------------- - - describe 'Chat#parse_completion_body' do - subject(:chat) { described_class::Chat } - - let(:basic_response) do - { - 'id' => 'msg_01', - 'type' => 'message', - 'model' => 'anthropic.claude-haiku-4-5-20251001-v1:0', - 'stop_reason' => 'end_turn', - 'content' => [{ 'type' => 'text', 'text' => 'Hello!' }], - 'usage' => { - 'input_tokens' => 20, - 'output_tokens' => 5, - 'cache_read_input_tokens' => 0, - 'cache_creation_input_tokens' => 0 - } - } - end - - it 'extracts text content' do - msg = chat.parse_completion_body(basic_response, raw: nil) - expect(msg.content).to eq('Hello!') - end - - it 'extracts stop_reason as finish_reason' do - msg = chat.parse_completion_body(basic_response, raw: nil) - expect(msg.finish_reason).to eq('end_turn') - end - - it 'extracts model_id from response' do - msg = chat.parse_completion_body(basic_response, raw: nil) - expect(msg.model_id).to eq('anthropic.claude-haiku-4-5-20251001-v1:0') - end - - it 'extracts output_tokens from usage' do - msg = chat.parse_completion_body(basic_response, raw: nil) - expect(msg.output_tokens).to eq(5) - end - - it 'extracts input_tokens net of cache tokens' do - data = basic_response.merge( - 'usage' => { - 'input_tokens' => 100, - 'output_tokens' => 5, - 'cache_read_input_tokens' => 40, - 'cache_creation_input_tokens' => 10 - } - ) - msg = chat.parse_completion_body(data, raw: nil) - expect(msg.input_tokens).to eq(50) - expect(msg.cached_tokens).to eq(40) - expect(msg.cache_creation_tokens).to eq(10) - end - - it 'extracts tool_use blocks as tool_calls' do - data = basic_response.merge( - 'stop_reason' => 'tool_use', - 'content' => [ - { - 'type' => 'tool_use', - 'id' => 'call_abc', - 'name' => 'my_tool', - 'input' => { 'arg' => 'val' } - } - ] - ) - msg = chat.parse_completion_body(data, raw: nil) - expect(msg.tool_calls).not_to be_nil - tc = msg.tool_calls['call_abc'] - expect(tc.name).to eq('my_tool') - expect(tc.arguments).to eq({ 'arg' => 'val' }) - end - - it 'extracts thinking blocks' do - data = basic_response.merge( - 'content' => [ - { 'type' => 'thinking', 'thinking' => 'I am thinking', 'signature' => 'sig123' }, - { 'type' => 'text', 'text' => 'Done' } - ] - ) - msg = chat.parse_completion_body(data, raw: nil) - expect(msg.thinking.text).to eq('I am thinking') - expect(msg.thinking.signature).to eq('sig123') - end - - it 'returns nil for empty data' do - expect(chat.parse_completion_body(nil, raw: nil)).to be_nil - expect(chat.parse_completion_body({}, raw: nil)).to be_nil - end - end - - # --------------------------------------------------------------------------- - # build_chunk (streaming) - # --------------------------------------------------------------------------- - - describe 'Streaming#build_chunk' do - let(:streaming) do - described_class.allocate.tap do |obj| - obj.instance_variable_set(:@model, build_model('anthropic.claude-haiku-4-5-20251001-v1:0')) - obj.instance_variable_set(:@config, build_config) - end - end - - it 'extracts input_tokens from message_start' do - event = { - 'type' => 'message_start', - 'message' => { - 'id' => 'msg_01', - 'model' => 'anthropic.claude-sonnet-4-6', - 'usage' => { 'input_tokens' => 42 } - } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.input_tokens).to eq(42) - end - - it 'extracts cache usage fields from message_start, netting them out of input_tokens' do - event = { - 'type' => 'message_start', - 'message' => { - 'model' => 'anthropic.claude-sonnet-4-6', - 'usage' => { - 'input_tokens' => 100, - 'cache_read_input_tokens' => 60, - 'cache_creation_input_tokens' => 30 - } - } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.cached_tokens).to eq(60) - expect(chunk.cache_creation_tokens).to eq(30) - expect(chunk.input_tokens).to eq(10) - end - - it 'extracts model_id from message_start' do - event = { - 'type' => 'message_start', - 'message' => { - 'model' => 'anthropic.claude-sonnet-4-6', - 'usage' => { 'input_tokens' => 10 } - } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.model_id).to eq('anthropic.claude-sonnet-4-6') - end - - it 'extracts text from content_block_delta text_delta' do - event = { - 'type' => 'content_block_delta', - 'index' => 0, - 'delta' => { 'type' => 'text_delta', 'text' => 'Hello' } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.content).to eq('Hello') - end - - it 'extracts partial_json from content_block_delta input_json_delta' do - event = { - 'type' => 'content_block_delta', - 'index' => 1, - 'delta' => { 'type' => 'input_json_delta', 'partial_json' => '{"key":' } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.tool_calls).not_to be_nil - expect(chunk.tool_calls[nil].arguments).to eq('{"key":') - end - - it 'extracts thinking text from thinking_delta' do - event = { - 'type' => 'content_block_delta', - 'index' => 0, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'pondering...' } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.thinking.text).to eq('pondering...') - end - - it 'extracts thinking signature from signature_delta' do - event = { - 'type' => 'content_block_delta', - 'index' => 0, - 'delta' => { 'type' => 'signature_delta', 'signature' => 'sig-abc' } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.thinking.signature).to eq('sig-abc') - end - - it 'extracts output_tokens and stop_reason from message_delta' do - event = { - 'type' => 'message_delta', - 'delta' => { 'stop_reason' => 'end_turn' }, - 'usage' => { 'output_tokens' => 17 } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.output_tokens).to eq(17) - expect(chunk.finish_reason).to eq('end_turn') - end - - it 'returns a chunk for message_stop without error' do - event = { 'type' => 'message_stop' } - expect { streaming.send(:build_chunk, event) }.not_to raise_error - end - - it 'extracts tool_call from content_block_start tool_use' do - event = { - 'type' => 'content_block_start', - 'index' => 0, - 'content_block' => { - 'type' => 'tool_use', - 'id' => 'call_xyz', - 'name' => 'search' - } - } - chunk = streaming.send(:build_chunk, event) - expect(chunk.tool_calls).not_to be_nil - tc = chunk.tool_calls['call_xyz'] - expect(tc.name).to eq('search') - end - - it 'accumulates streaming chunks into a final message' do - accumulator = RubyLLM::StreamAccumulator.new - - events = [ - { 'type' => 'message_start', 'message' => { 'model' => 'test-model', 'usage' => { 'input_tokens' => 5 } } }, - { 'type' => 'content_block_delta', 'index' => 0, - 'delta' => { 'type' => 'text_delta', 'text' => 'Hello' } }, - { 'type' => 'content_block_delta', 'index' => 0, - 'delta' => { 'type' => 'text_delta', 'text' => ' world' } }, - { 'type' => 'message_delta', 'delta' => { 'stop_reason' => 'end_turn' }, - 'usage' => { 'output_tokens' => 2 } } - ] - - events.each { |e| accumulator.add(streaming.send(:build_chunk, e)) } - message = accumulator.to_message(nil) - - expect(message.content).to eq('Hello world') - expect(message.output_tokens).to eq(2) - end - end - - # --------------------------------------------------------------------------- - # Streaming multi-block thinking parse + replay - # --------------------------------------------------------------------------- - - describe 'Streaming multi-block thinking' do - let(:streaming) do - described_class.allocate.tap do |obj| - obj.instance_variable_set(:@model, build_model('anthropic.claude-haiku-4-5-20251001-v1:0')) - obj.instance_variable_set(:@config, build_config) - end - end - - # Feeds events through build_chunk with a single shared thinking_state hash, - # mirroring how stream_response threads it across the whole event stream. - def accumulate(events) - accumulator = RubyLLM::StreamAccumulator.new - thinking_state = {} - events.each { |e| accumulator.add(streaming.send(:build_chunk, e, thinking_state)) } - accumulator.to_message(nil) - end - - it 'preserves a redacted_thinking block followed by a thinking block, verbatim and in order' do - events = [ - { 'type' => 'message_start', 'message' => { 'model' => 'test-model', 'usage' => { 'input_tokens' => 5 } } }, - { 'type' => 'content_block_start', 'index' => 0, - 'content_block' => { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' } }, - { 'type' => 'content_block_stop', 'index' => 0 }, - { 'type' => 'content_block_start', 'index' => 1, - 'content_block' => { 'type' => 'thinking', 'thinking' => '', 'signature' => '' } }, - { 'type' => 'content_block_delta', 'index' => 1, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'step two' } }, - { 'type' => 'content_block_delta', 'index' => 1, - 'delta' => { 'type' => 'signature_delta', 'signature' => 'sig-2' } }, - { 'type' => 'content_block_stop', 'index' => 1 }, - { 'type' => 'content_block_start', 'index' => 2, 'content_block' => { 'type' => 'text', 'text' => '' } }, - { 'type' => 'content_block_delta', 'index' => 2, - 'delta' => { 'type' => 'text_delta', 'text' => 'Done' } }, - { 'type' => 'content_block_stop', 'index' => 2 }, - { 'type' => 'message_delta', 'delta' => { 'stop_reason' => 'end_turn' }, - 'usage' => { 'output_tokens' => 5 } } - ] - - message = accumulate(events) - - expect(message.thinking.blocks).to eq( - [ - { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' }, - { 'type' => 'thinking', 'thinking' => 'step two', 'signature' => 'sig-2' } - ] - ) - expect(message.content).to eq('Done') - end - - it 'round-trips a streamed multi-block thinking turn through format_thinking_blocks' do - events = [ - { 'type' => 'content_block_start', 'index' => 0, - 'content_block' => { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' } }, - { 'type' => 'content_block_stop', 'index' => 0 }, - { 'type' => 'content_block_start', 'index' => 1, - 'content_block' => { 'type' => 'thinking', 'thinking' => '', 'signature' => '' } }, - { 'type' => 'content_block_delta', 'index' => 1, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'step two' } }, - { 'type' => 'content_block_delta', 'index' => 1, - 'delta' => { 'type' => 'signature_delta', 'signature' => 'sig-2' } }, - { 'type' => 'content_block_stop', 'index' => 1 } - ] - - message = accumulate(events) - chat = described_class::Chat - formatted = chat.format_messages([message]) - thinking_blocks = formatted.first[:content].select do |b| - %w[thinking redacted_thinking].include?(b['type']) - end - - expect(thinking_blocks).to eq( - [ - { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' }, - { 'type' => 'thinking', 'thinking' => 'step two', 'signature' => 'sig-2' } - ] - ) - end - - it 'tracks interleaved thinking blocks separately when a tool_use block sits between them' do - events = [ - { 'type' => 'content_block_start', 'index' => 0, - 'content_block' => { 'type' => 'thinking', 'thinking' => '', 'signature' => '' } }, - { 'type' => 'content_block_delta', 'index' => 0, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'first thought' } }, - { 'type' => 'content_block_delta', 'index' => 0, - 'delta' => { 'type' => 'signature_delta', 'signature' => 'sig-1' } }, - { 'type' => 'content_block_stop', 'index' => 0 }, - { 'type' => 'content_block_start', 'index' => 1, - 'content_block' => { 'type' => 'tool_use', 'id' => 'call_1', 'name' => 'search' } }, - { 'type' => 'content_block_delta', 'index' => 1, - 'delta' => { 'type' => 'input_json_delta', 'partial_json' => '{}' } }, - { 'type' => 'content_block_stop', 'index' => 1 }, - { 'type' => 'content_block_start', 'index' => 2, - 'content_block' => { 'type' => 'thinking', 'thinking' => '', 'signature' => '' } }, - { 'type' => 'content_block_delta', 'index' => 2, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'second thought' } }, - { 'type' => 'content_block_delta', 'index' => 2, - 'delta' => { 'type' => 'signature_delta', 'signature' => 'sig-2' } }, - { 'type' => 'content_block_stop', 'index' => 2 } - ] - - message = accumulate(events) - - expect(message.thinking.blocks).to eq( - [ - { 'type' => 'thinking', 'thinking' => 'first thought', 'signature' => 'sig-1' }, - { 'type' => 'thinking', 'thinking' => 'second thought', 'signature' => 'sig-2' } - ] - ) - expect(message.tool_calls['call_1'].name).to eq('search') - end - - it 'drops a thinking block left open when the turn is truncated before content_block_stop' do - events = [ - { 'type' => 'content_block_start', 'index' => 0, - 'content_block' => { 'type' => 'thinking', 'thinking' => '', 'signature' => '' } }, - { 'type' => 'content_block_delta', 'index' => 0, - 'delta' => { 'type' => 'thinking_delta', 'thinking' => 'cut off mid-thought' } }, - { 'type' => 'message_delta', 'delta' => { 'stop_reason' => 'max_tokens' }, - 'usage' => { 'output_tokens' => 3 } } - ] - - message = accumulate(events) - - expect(message.thinking.blocks).to be_nil - expect(message.finish_reason).to eq('max_tokens') - end - end - - # --------------------------------------------------------------------------- - # Model coverage — two different Claude models + one ARN - # --------------------------------------------------------------------------- - - describe 'URL generation for multiple model ids' do - [ - 'anthropic.claude-haiku-4-5-20251001-v1:0', - 'anthropic.claude-sonnet-4-5-20250929-v1:0' - ].each do |model_id| - it "generates /invoke URL for #{model_id}" do - inst = make_instance(model_id: model_id) - expect(inst.send(:completion_url)).to eq("/model/#{model_id}/invoke") - end - end - - it 'percent-encodes slashes in ARN model ids in invoke URL' do - arn = 'arn:aws:bedrock:us-west-2:999999999999:application-inference-profile/my-profile' - inst = make_instance(model_id: arn) - url = inst.send(:completion_url) - expect(url).to include('%2F') - expect(url).not_to include('application-inference-profile/') - end - - it 'percent-encodes slashes in ARN model ids in invoke-with-response-stream URL' do - arn = 'arn:aws:bedrock:us-west-2:999999999999:application-inference-profile/my-profile' - inst = make_instance(model_id: arn) - url = inst.send(:stream_url) - expect(url).to include('%2F') - expect(url).not_to include('application-inference-profile/') - end - end - - # --------------------------------------------------------------------------- - # Coexistence / selection — bedrock_use_invoke_model - # --------------------------------------------------------------------------- - - describe 'Providers::Bedrock protocol selection' do # rubocop:disable RSpec/MultipleMemoizedHelpers - def build_bedrock(use_invoke_model: false) - config = RubyLLM::Configuration.new - config.bedrock_api_key = 'k' - config.bedrock_secret_key = 's' - config.bedrock_region = 'us-east-1' - config.bedrock_use_invoke_model = use_invoke_model - RubyLLM::Providers::Bedrock.new(config) - end - - def model_double(id, metadata: {}) - instance_double(RubyLLM::Model::Info, id: id, max_tokens: 4096, metadata: metadata) - end - - let(:haiku_id) { 'anthropic.claude-haiku-4-5-20251001-v1:0' } - let(:sonnet_id) { 'anthropic.claude-sonnet-4-6-20250514-v1:0' } - let(:nova_id) { 'amazon.nova-lite-v1:0' } - let(:arn_id) { 'arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/sonnet46' } - let(:arn_anthropic) { model_double(arn_id, metadata: { provider_name: 'Anthropic' }) } - let(:arn_no_vendor) { model_double(arn_id, metadata: {}) } - - context 'with bedrock_use_invoke_model: false (default)' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: false) } - - it 'routes Anthropic models to Converse' do - protocol = provider.protocol_for(model_double(haiku_id)) - expect(protocol).to be(RubyLLM::Protocols::Converse) - end - - it 'routes non-Anthropic models to Converse' do - protocol = provider.protocol_for(model_double(nova_id)) - expect(protocol).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with bedrock_use_invoke_model: true' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: true) } - - it 'routes Anthropic models to BedrockInvokeModel' do - protocol = provider.protocol_for(model_double(haiku_id)) - expect(protocol).to be(described_class) - end - - it 'routes non-Anthropic models to Converse regardless of flag' do - protocol = provider.protocol_for(model_double(nova_id)) - expect(protocol).to be(RubyLLM::Protocols::Converse) - end - - it 'routes ARN model ids with Anthropic provider_name to BedrockInvokeModel' do - protocol = provider.protocol_for(arn_anthropic) - expect(protocol).to be(described_class) - end - - it 'routes ARN model ids without provider_name to Converse and logs a warning' do - allow(RubyLLM.logger).to receive(:warn) - protocol = provider.protocol_for(arn_no_vendor) - expect(protocol).to be(RubyLLM::Protocols::Converse) - expect(RubyLLM.logger).to have_received(:warn).with(/cannot verify.*Anthropic-backed/) - end - end - - context 'with bedrock_use_invoke_model: [array of model ids]' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: [sonnet_id]) } - - it 'routes model A (in list) to BedrockInvokeModel' do - protocol = provider.protocol_for(model_double(sonnet_id)) - expect(protocol).to be(described_class) - end - - it 'routes model B (not in list) to Converse' do - protocol = provider.protocol_for(model_double(haiku_id)) - expect(protocol).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with bedrock_use_invoke_model: callable predicate' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:selector) { ->(model) { model.id == sonnet_id } } - let(:provider) { build_bedrock(use_invoke_model: selector) } - - it 'routes model matching predicate to BedrockInvokeModel' do - protocol = provider.protocol_for(model_double(sonnet_id)) - expect(protocol).to be(described_class) - end - - it 'routes model not matching predicate to Converse' do - protocol = provider.protocol_for(model_double(haiku_id)) - expect(protocol).to be(RubyLLM::Protocols::Converse) - end - end - end - - # --------------------------------------------------------------------------- - # render_payload — schema/citations warnings - # --------------------------------------------------------------------------- - - describe 'Chat#render_payload warnings' do - it 'logs a warning when schema is passed' do - inst = make_instance - model = inst.instance_variable_get(:@model) - schema = { name: 'out', schema: { type: 'object' } } - allow(RubyLLM.logger).to receive(:warn) - inst.send(:render_payload, [], tools: {}, temperature: nil, model: model, schema: schema) - expect(RubyLLM.logger).to have_received(:warn).with(/structured output.*schema.*BedrockInvokeModel/i) - end - - it 'logs a warning when citations is true' do - inst = make_instance - model = inst.instance_variable_get(:@model) - allow(RubyLLM.logger).to receive(:warn) - inst.send(:render_payload, [], tools: {}, temperature: nil, model: model, citations: true) - expect(RubyLLM.logger).to have_received(:warn).with(/citations.*BedrockInvokeModel/i) - end - - it 'does not warn when neither schema nor citations are set' do - inst = make_instance - model = inst.instance_variable_get(:@model) - allow(RubyLLM.logger).to receive(:warn) - inst.send(:render_payload, [], tools: {}, temperature: nil, model: model) - expect(RubyLLM.logger).not_to have_received(:warn) - end - end - - # --------------------------------------------------------------------------- - # Large-file upload overrides - # --------------------------------------------------------------------------- - - describe 'Chat large-file upload overrides' do - subject(:chat) { described_class::Chat } - - it 'supports provider file references' do - inst = make_instance - expect(inst.send(:supports_provider_file_references?)).to be(true) - end - - it 'uses the same 4.5 MB inline threshold as Converse' do - inst = make_instance - expect(inst.send(:default_large_file_upload_threshold)) - .to eq(RubyLLM::Protocols::Converse::Chat::BEDROCK_INLINE_DOCUMENT_LIMIT) - end - - it 'marks pdf attachments as uploadable' do - pdf = instance_double(RubyLLM::Attachment, pdf?: true, document?: false, text?: false) - inst = make_instance - expect(inst.send(:provider_file_attachable?, pdf)).to be(true) - end - - it 'marks document attachments as uploadable' do - doc = instance_double(RubyLLM::Attachment, pdf?: false, document?: true, text?: false) - inst = make_instance - expect(inst.send(:provider_file_attachable?, doc)).to be(true) - end - - it 'marks text attachments as uploadable' do - txt = instance_double(RubyLLM::Attachment, pdf?: false, document?: false, text?: true) - inst = make_instance - expect(inst.send(:provider_file_attachable?, txt)).to be(true) - end - - it 'does not mark image attachments as uploadable' do - img = instance_double(RubyLLM::Attachment, pdf?: false, document?: false, text?: false) - inst = make_instance - expect(inst.send(:provider_file_attachable?, img)).to be(false) - end - end - - # --------------------------------------------------------------------------- - # format_thinking_fields — effort-level budget_tokens mapping - # --------------------------------------------------------------------------- - - describe 'Chat#format_thinking_fields' do - subject(:chat) { described_class::Chat } - - %w[low medium high max xhigh].each do |effort_level| - it "maps effort '#{effort_level}' to adaptive thinking with effort in output_config" do - thinking = RubyLLM::Thinking::Config.new(effort: effort_level) - result = chat.format_thinking_fields(thinking) - expect(result).to eq({ thinking: { type: 'adaptive' }, output_config: { effort: effort_level } }) - end - end - - it 'passes any effort string through to output_config verbatim' do - thinking = RubyLLM::Thinking::Config.new(effort: 'unknown_level') - result = chat.format_thinking_fields(thinking) - expect(result).to eq({ thinking: { type: 'adaptive' }, output_config: { effort: 'unknown_level' } }) - end - - it 'uses type: enabled with an explicit integer budget (manual budget opt-in)' do - thinking = RubyLLM::Thinking::Config.new(budget: 8_192) - result = chat.format_thinking_fields(thinking) - expect(result).to eq({ thinking: { type: 'enabled', budget_tokens: 8_192 } }) - end - - it 'returns nil when thinking is nil' do - expect(chat.format_thinking_fields(nil)).to be_nil - end - - it 'returns nil for effort none' do - thinking = RubyLLM::Thinking::Config.new(effort: 'none') - expect(chat.format_thinking_fields(thinking)).to be_nil - end - end - - # --------------------------------------------------------------------------- - # Multi-block thinking parse + replay - # --------------------------------------------------------------------------- - - describe 'Chat multi-block thinking parse and replay' do - subject(:chat) { described_class::Chat } - - let(:multi_block_response) do - { - 'id' => 'msg_01', - 'type' => 'message', - 'model' => 'anthropic.claude-sonnet-4-6', - 'stop_reason' => 'end_turn', - 'content' => [ - { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' }, - { 'type' => 'thinking', 'thinking' => 'step two', 'signature' => 'sig-2' }, - { 'type' => 'text', 'text' => 'Done' } - ], - 'usage' => { 'input_tokens' => 10, 'output_tokens' => 5 } - } - end - - it 'preserves all thinking blocks in thinking.blocks, not just the first' do - msg = chat.parse_completion_body(multi_block_response, raw: nil) - expect(msg.thinking.blocks).to eq( - [ - { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' }, - { 'type' => 'thinking', 'thinking' => 'step two', 'signature' => 'sig-2' } - ] - ) - end - - it 'replays all thinking blocks verbatim when formatting the assistant message' do - original_blocks = [ - { 'type' => 'redacted_thinking', 'data' => 'opaque-blob-1' }, - { 'type' => 'thinking', 'thinking' => 'step two', 'signature' => 'sig-2' } - ] - thinking = RubyLLM::Thinking.build(text: 'step two', signature: 'sig-2', blocks: original_blocks) - message = RubyLLM::Message.new(role: :assistant, content: 'Done', thinking: thinking) - - result = chat.format_messages([message]) - thinking_blocks = result.first[:content].select do |b| - %w[thinking redacted_thinking].include?(b['type']) - end - expect(thinking_blocks).to eq(original_blocks) - end - end - - # --------------------------------------------------------------------------- - # URL image source rejection - # --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- - # Prompt caching — auto-injected breakpoints - # --------------------------------------------------------------------------- - - describe 'Chat#render_payload prompt caching' do - subject(:chat) { described_class::Chat } - - it 'injects cache_control on the last system block and last cacheable block of the final ' \ - 'message when caching is enabled' do - sys = RubyLLM::Message.new(role: :system, content: 'You are helpful') - msg = RubyLLM::Message.new(role: :user, content: 'Hello') - result = render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(result[:system].last[:cache_control]).to eq({ type: 'ephemeral' }) - expect(result[:messages].last[:content].last[:cache_control]).to eq({ type: 'ephemeral' }) - end - - it 'injects no cache_control anywhere when caching is disabled' do - sys = RubyLLM::Message.new(role: :system, content: 'You are helpful') - msg = RubyLLM::Message.new(role: :user, content: 'Hello') - result = render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: false }) - - expect(result[:system].any? { |b| b[:cache_control] }).to be(false) - expect(result[:messages].flat_map { |m| m[:content] }.any? { |b| b[:cache_control] }).to be(false) - end - - it 'defaults to enabled' do - sys = RubyLLM::Message.new(role: :system, content: 'You are helpful') - result = render_payload([sys]) - expect(result[:system].last[:cache_control]).to eq({ type: 'ephemeral' }) - end - - it 'translates a symbol-keyed Converse raw system message and attaches cache_control from cachePoint' do - raw = RubyLLM::Content::Raw.new([{ text: 'PROMPT' }, { cachePoint: { type: 'default' } }]) - sys = RubyLLM::Message.new(role: :system, content: raw) - result = render_payload([sys], config_overrides: { bedrock_invoke_model_prompt_caching: false }) - - expect(result[:system]).to eq([{ type: 'text', text: 'PROMPT', cache_control: { type: 'ephemeral' } }]) - end - - it 'translates a string-keyed Converse raw system message and attaches cache_control from cachePoint' do - raw = RubyLLM::Content::Raw.new([{ 'text' => 'PROMPT' }, { 'cachePoint' => { 'type' => 'default' } }]) - sys = RubyLLM::Message.new(role: :system, content: raw) - result = render_payload([sys], config_overrides: { bedrock_invoke_model_prompt_caching: false }) - - expect(result[:system]).to eq([{ type: 'text', text: 'PROMPT', cache_control: { type: 'ephemeral' } }]) - end - - it 'passes through a ttl carried on the cachePoint' do - raw = RubyLLM::Content::Raw.new([{ text: 'PROMPT' }, { cachePoint: { type: 'default', ttl: '1h' } }]) - sys = RubyLLM::Message.new(role: :system, content: raw) - result = render_payload([sys], config_overrides: { bedrock_invoke_model_prompt_caching: false }) - - expect(result[:system]).to eq( - [{ type: 'text', text: 'PROMPT', cache_control: { type: 'ephemeral', ttl: '1h' } }] - ) - end - - it 'does not inject a breakpoint when 4 translated breakpoints already exist (budget exhausted)' do - raw = RubyLLM::Content::Raw.new( - [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] - ) - sys = RubyLLM::Message.new(role: :system, content: raw) - - user_raw = RubyLLM::Content::Raw.new( - [ - { type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'd', cache_control: { type: 'ephemeral' } } - ] - ) - msg = RubyLLM::Message.new(role: :user, content: user_raw) - - result = render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - total = count_cache_controls(result) - expect(total).to eq(4) - end - - it 'injects only the tail breakpoint (not system) when exactly one budget slot remains' do - raw = RubyLLM::Content::Raw.new( - [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] - ) - sys_translated = RubyLLM::Message.new(role: :system, content: raw) - - user_raw = RubyLLM::Content::Raw.new( - [ - { type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'c', cache_control: { type: 'ephemeral' } } - ] - ) - other_sys = RubyLLM::Message.new(role: :system, content: 'plain system text') - msg = RubyLLM::Message.new(role: :user, content: user_raw) - tail_msg = RubyLLM::Message.new(role: :user, content: 'final turn text') - - result = render_payload([sys_translated, other_sys, msg, tail_msg], - config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(result[:system].last[:cache_control]).to be_nil - expect(result[:messages].last[:content].last[:cache_control]).to eq({ type: 'ephemeral' }) - expect(count_cache_controls(result)).to eq(4) - end - - it 'skips thinking blocks and places the tail breakpoint on the preceding tool_result/text block' do - thinking = RubyLLM::Thinking.build( - blocks: [{ 'type' => 'redacted_thinking', 'data' => 'opaque' }] - ) - msg = RubyLLM::Message.new(role: :assistant, content: 'reply text', thinking: thinking) - - inst = make_instance(config_overrides: { bedrock_invoke_model_prompt_caching: true }) - model = inst.instance_variable_get(:@model) - result = inst.send(:render_payload, [msg], tools: {}, temperature: nil, model: model) - - content = result[:messages].last[:content] - thinking_block = content.find { |b| b['type'] == 'redacted_thinking' } - text_block = content.find { |b| b[:type] == 'text' } - - expect(thinking_block[:cache_control]).to be_nil - expect(text_block[:cache_control]).to eq({ type: 'ephemeral' }) - end - - it 'passes Anthropic-format raw blocks through byte-identical, preserving existing cache_control' do - original = [{ type: 'text', text: 'already anthropic', cache_control: { type: 'ephemeral', ttl: '1h' } }] - raw = RubyLLM::Content::Raw.new(original) - sys = RubyLLM::Message.new(role: :system, content: raw) - - result = render_payload([sys], config_overrides: { bedrock_invoke_model_prompt_caching: false }) - - expect(result[:system]).to eq(original) - end - - it 'preserves a pre-existing cache_control (including ttl) on the tail block instead of overwriting it' do - raw = RubyLLM::Content::Raw.new( - [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral', ttl: '1h' } }] - ) - msg = RubyLLM::Message.new(role: :user, content: raw) - - result = render_payload([msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(result[:messages].last[:content].last[:cache_control]).to eq({ type: 'ephemeral', ttl: '1h' }) - end - - it 'does not mutate the caller-owned Content::Raw hash objects passed in via render_payload' do - original_system = [{ text: 'PROMPT' }] - original_message = [{ text: 'Hello' }] - sys = RubyLLM::Message.new(role: :system, content: RubyLLM::Content::Raw.new(original_system)) - msg = RubyLLM::Message.new(role: :user, content: RubyLLM::Content::Raw.new(original_message)) - - render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(original_system.first).not_to have_key(:cache_control) - expect(original_message.first).not_to have_key(:cache_control) - end - - it 'does not mutate a caller-owned Anthropic-format hash that already has a type key' do - original = { type: 'text', text: 'already anthropic' } - raw = RubyLLM::Content::Raw.new([original]) - msg = RubyLLM::Message.new(role: :user, content: raw) - - render_payload([msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(original).not_to have_key(:cache_control) - end - - it 'counts a string-keyed cache_control block toward the 4-breakpoint budget' do - raw = RubyLLM::Content::Raw.new( - [{ 'type' => 'text', 'text' => 'a', 'cache_control' => { 'type' => 'ephemeral' } }] - ) - sys = RubyLLM::Message.new(role: :system, content: raw) - - user_raw = RubyLLM::Content::Raw.new( - [ - { type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'd', cache_control: { type: 'ephemeral' } } - ] - ) - msg = RubyLLM::Message.new(role: :user, content: user_raw) - - result = render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(count_cache_controls(result)).to eq(4) - end - - it 'dedupes against a string-keyed cache_control when deciding whether to inject the system breakpoint' do - raw = RubyLLM::Content::Raw.new( - [{ 'type' => 'text', 'text' => 'PROMPT', 'cache_control' => { 'type' => 'ephemeral' } }] - ) - sys = RubyLLM::Message.new(role: :system, content: raw) - msg = RubyLLM::Message.new(role: :user, content: 'Hello') - - result = render_payload([sys, msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - expect(result[:system].count { |b| b['cache_control'] || b[:cache_control] }).to eq(1) - end - - it 'finds a string-keyed type block as the tail cacheable block' do - raw = RubyLLM::Content::Raw.new([{ 'type' => 'text', 'text' => 'final turn' }]) - msg = RubyLLM::Message.new(role: :user, content: raw) - - result = render_payload([msg], config_overrides: { bedrock_invoke_model_prompt_caching: true }) - - block = result[:messages].last[:content].last - expect(block['cache_control'] || block[:cache_control]).to eq({ type: 'ephemeral' }) - end - - def count_cache_controls(payload) - message_blocks = (payload[:messages] || []).flat_map { |m| m[:content] } - all_blocks = (payload[:system] || []) + (payload[:tools] || []) + message_blocks - all_blocks.count { |b| b.is_a?(Hash) && (b[:cache_control] || b['cache_control']) } - end - end - - # --------------------------------------------------------------------------- - # Raw translation — Converse-format block translation - # --------------------------------------------------------------------------- - - describe 'Chat.translate_raw_blocks' do - subject(:chat) { described_class::Chat } - - it 'converts a symbol-keyed Converse text block to Anthropic text block' do - result = chat.translate_raw_blocks([{ text: 'hi' }]) - expect(result).to eq([{ type: 'text', text: 'hi' }]) - end - - it 'converts a string-keyed Converse text block to Anthropic text block' do - result = chat.translate_raw_blocks([{ 'text' => 'hi' }]) - expect(result).to eq([{ type: 'text', text: 'hi' }]) - end - - it 'drops a cachePoint with no preceding block' do - result = chat.translate_raw_blocks([{ cachePoint: { type: 'default' } }]) - expect(result).to eq([]) - end - - it 'leaves an unrecognized block untouched' do - block = { foo: 'bar' } - result = chat.translate_raw_blocks([block]) - expect(result).to eq([block]) - end - end - - describe 'Chat#format_tool_result_content prompt caching translation' do - subject(:chat) { described_class::Chat } - - it 'translates Converse-format raw tool_result content' do - raw = RubyLLM::Content::Raw.new([{ text: 'result' }, { cachePoint: { type: 'default' } }]) - msg = RubyLLM::Message.new(role: :tool, content: raw, tool_call_id: 'call_1') - result = chat.format_messages([msg]) - block = result.first[:content].first - expect(block[:content]).to eq([{ type: 'text', text: 'result', cache_control: { type: 'ephemeral' } }]) - end - end - - describe 'Chat#format_image_attachment' do - subject(:chat) { described_class::Chat } - - it 'raises UnsupportedAttachmentError for URL-sourced images' do - attachment = instance_double( - RubyLLM::Attachment, - url?: true, - source: URI.parse('https://example.com/photo.jpg'), - mime_type: 'image/jpeg', - encoded: nil - ) - expect { chat.format_image_attachment(attachment) } - .to raise_error(RubyLLM::UnsupportedAttachmentError, /Bedrock InvokeModel.*URL image/) - end - - it 'returns a base64 block for non-URL images' do - attachment = instance_double( - RubyLLM::Attachment, - url?: false, - mime_type: 'image/jpeg', - encoded: 'base64data' - ) - result = chat.format_image_attachment(attachment) - expect(result[:source][:type]).to eq('base64') - expect(result[:source][:data]).to eq('base64data') - end - end -end diff --git a/spec/ruby_llm/providers/bedrock_spec.rb b/spec/ruby_llm/providers/bedrock_spec.rb index e5b2c9e77..31ed1d0c4 100644 --- a/spec/ruby_llm/providers/bedrock_spec.rb +++ b/spec/ruby_llm/providers/bedrock_spec.rb @@ -282,11 +282,9 @@ def model_double(id, metadata: {}) end end - describe '#protocol_for / #invoke_model? / #anthropic_model?' do # rubocop:disable RSpec/MultipleMemoizedHelpers - def build_bedrock(use_invoke_model: false) - config = bedrock_config(api_key: 'k', secret_key: 's') - config.bedrock_use_invoke_model = use_invoke_model - described_class.new(config) + describe '#protocol_for' do + def build_bedrock + described_class.new(bedrock_config(api_key: 'k', secret_key: 's')) end def model_double(id, metadata: {}, provider: 'bedrock') @@ -301,167 +299,30 @@ def model_double(id, metadata: {}, provider: 'bedrock') let(:haiku_id) { 'anthropic.claude-haiku-4-5-20251001-v1:0' } let(:nova_id) { 'amazon.nova-lite-v1:0' } - let(:llama_id) { 'meta.llama3-8b-instruct-v1:0' } - let(:arn_id) { 'arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/p' } - let(:us_sonnet_id) { 'us.anthropic.claude-sonnet-5' } - let(:eu_haiku_id) { 'eu.anthropic.claude-haiku-4-5-20251001-v1:0' } - let(:us_nova_id) { 'us.amazon.nova-pro-v1:0' } - - context 'with bedrock_use_invoke_model: false (default)' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: false) } - - it 'routes all models to Converse' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::Converse) - expect(provider.protocol_for(model_double(nova_id))).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with bedrock_use_invoke_model: nil' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: nil) } - - it 'routes all models to Converse' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with bedrock_use_invoke_model: true' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: true) } - - it 'routes Anthropic models (anthropic.* prefix) to BedrockInvokeModel' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes non-Anthropic models to Converse regardless of flag' do - expect(provider.protocol_for(model_double(nova_id))).to be(RubyLLM::Protocols::Converse) - expect(provider.protocol_for(model_double(llama_id))).to be(RubyLLM::Protocols::Converse) - end - - it 'routes ARN ids with Anthropic provider_name to BedrockInvokeModel' do - model = model_double(arn_id, metadata: { provider_name: 'Anthropic' }) - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes ARN ids without provider_name to Converse with a warning' do - allow(RubyLLM.logger).to receive(:warn) - expect(provider.protocol_for(model_double(arn_id))).to be(RubyLLM::Protocols::Converse) - expect(RubyLLM.logger).to have_received(:warn).with(/cannot verify.*Anthropic-backed/) - end - - it 'routes cross-region inference profile ids (us.anthropic.*) to BedrockInvokeModel' do - model = model_double(us_sonnet_id, provider: 'bedrock') - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes cross-region inference profile ids (eu.anthropic.*) to BedrockInvokeModel' do - model = model_double(eu_haiku_id, provider: 'bedrock') - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes cross-region non-Anthropic profile ids (us.amazon.*) to Converse' do - model = model_double(us_nova_id, provider: 'bedrock') - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with Array selector' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:sonnet_id) { 'anthropic.claude-sonnet-4-6-20250514-v1:0' } - let(:provider) { build_bedrock(use_invoke_model: [sonnet_id]) } - - it 'routes only listed model ids to BedrockInvokeModel' do - expect(provider.protocol_for(model_double(sonnet_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes unlisted Anthropic models to Converse' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with Array selector containing a cross-region inference profile id' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: [us_sonnet_id]) } - - it 'routes the listed cross-region model id to BedrockInvokeModel' do - model = model_double(us_sonnet_id, provider: 'bedrock') - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes an unlisted cross-region model id to Converse' do - model = model_double(eu_haiku_id, provider: 'bedrock') - expect(provider.protocol_for(model)).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with Proc/lambda selector' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:sonnet_id) { 'anthropic.claude-sonnet-4-6-20250514-v1:0' } - let(:selector) { ->(m) { m.id == sonnet_id } } - let(:provider) { build_bedrock(use_invoke_model: selector) } - - it 'routes models where the callable returns true to BedrockInvokeModel' do - expect(provider.protocol_for(model_double(sonnet_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes models where the callable returns false to Converse' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::Converse) - end - end - - context 'with an unverifiable ARN id (no provider_name metadata)' do # rubocop:disable RSpec/MultipleMemoizedHelpers - before { allow(RubyLLM.logger).to receive(:warn) } - - it 'routes to BedrockInvokeModel when an Array selector lists the ARN' do - provider = build_bedrock(use_invoke_model: [arn_id]) - expect(provider.protocol_for(model_double(arn_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - it 'routes to BedrockInvokeModel when a Proc selector returns true' do - provider = build_bedrock(use_invoke_model: ->(_m) { true }) - expect(provider.protocol_for(model_double(arn_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end - - it 'routes to Converse when a Proc selector returns false' do - provider = build_bedrock(use_invoke_model: ->(_m) { false }) - expect(provider.protocol_for(model_double(arn_id))).to be(RubyLLM::Protocols::Converse) - end + it 'routes standard Bedrock models to Converse' do + provider = build_bedrock - it 'still requires positive verification under the blanket true selector' do - provider = build_bedrock(use_invoke_model: true) - expect(provider.protocol_for(model_double(arn_id))).to be(RubyLLM::Protocols::Converse) - expect(RubyLLM.logger).to have_received(:warn).with(/cannot verify.*Anthropic-backed/) - end + expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::Converse) + expect(provider.protocol_for(model_double(nova_id))).to be(RubyLLM::Protocols::Converse) end - context 'with provably non-Anthropic ids under explicit selectors' do # rubocop:disable RSpec/MultipleMemoizedHelpers - it 'never routes a bare vendor id, even when an Array selector lists it' do - provider = build_bedrock(use_invoke_model: [nova_id]) - expect(provider.protocol_for(model_double(nova_id))).to be(RubyLLM::Protocols::Converse) - end + it 'routes GPT-5.6 models to Mantle Responses' do + provider = build_bedrock - it 'never routes a cross-region vendor id, even when a Proc selector returns true' do - provider = build_bedrock(use_invoke_model: ->(_m) { true }) - expect(provider.protocol_for(model_double(us_nova_id))).to be(RubyLLM::Protocols::Converse) - expect(provider.protocol_for(model_double(llama_id))).to be(RubyLLM::Protocols::Converse) + %w[openai.gpt-5.6-sol openai.gpt-5.6-terra openai.gpt-5.6-luna].each do |id| + expect(provider.protocol_for(model_double(id))).to be(RubyLLM::Protocols::MantleResponses) end end - context 'with GPT-5.6 (mantle-only) and gpt-oss (bedrock-runtime) model ids' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:provider) { build_bedrock(use_invoke_model: true) } + it 'keeps routing gpt-oss models to Converse' do + provider = build_bedrock - it 'routes openai.gpt-5.6-sol/-terra/-luna to MantleResponses regardless of bedrock_use_invoke_model' do - %w[openai.gpt-5.6-sol openai.gpt-5.6-terra openai.gpt-5.6-luna].each do |id| - expect(provider.protocol_for(model_double(id))).to be(RubyLLM::Protocols::MantleResponses) - end - end - - it 'keeps routing openai.gpt-oss-120b and openai.gpt-oss-20b to Converse' do - expect(provider.protocol_for(model_double('openai.gpt-oss-120b'))).to be(RubyLLM::Protocols::Converse) - expect(provider.protocol_for(model_double('openai.gpt-oss-20b'))).to be(RubyLLM::Protocols::Converse) - end - - it 'keeps routing anthropic.* ids to BedrockInvokeModel exactly as before' do - expect(provider.protocol_for(model_double(haiku_id))).to be(RubyLLM::Protocols::BedrockInvokeModel) - end + expect(provider.protocol_for(model_double('openai.gpt-oss-120b'))).to be(RubyLLM::Protocols::Converse) + expect(provider.protocol_for(model_double('openai.gpt-oss-20b'))).to be(RubyLLM::Protocols::Converse) end - describe '#mantle_only_model?' do # rubocop:disable RSpec/MultipleMemoizedHelpers + describe '#mantle_only_model?' do let(:bedrock) { build_bedrock } it 'matches openai.gpt-5.x ids narrowly, not the broad openai. prefix' do @@ -470,64 +331,6 @@ def model_double(id, metadata: {}, provider: 'bedrock') expect(bedrock.send(:mantle_only_model?, model_double('openai.gpt-5.5'))).to be(true) end end - - describe 'anthropic_model? directly' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:bedrock) { build_bedrock } - - it 'returns true for anthropic.* model ids' do - expect(bedrock.send(:anthropic_model?, model_double('anthropic.claude-3-haiku'))).to be(true) - end - - it 'returns false for amazon.* (Nova) model ids' do - expect(bedrock.send(:anthropic_model?, model_double('amazon.nova-lite-v1:0'))).to be(false) - end - - it 'returns false for meta.* (Llama) model ids' do - expect(bedrock.send(:anthropic_model?, model_double('meta.llama3-8b-instruct-v1:0'))).to be(false) - end - - it 'returns false for other NON_ANTHROPIC_PREFIXES vendors' do - %w[ai21. cohere. mistral. writer. stability.].each do |prefix| - expect(bedrock.send(:anthropic_model?, model_double("#{prefix}some-model"))).to be(false) - end - end - - it 'returns true for cross-region inference profile ids regardless of geo prefix' do - %w[us. eu. apac. global. jp. au. us-gov.].each do |geo| - id = "#{geo}anthropic.claude-sonnet-5" - expect(bedrock.send(:anthropic_model?, model_double(id, provider: 'bedrock'))).to be(true) - end - end - - it 'returns false for cross-region profile ids of non-Anthropic vendors' do - expect(bedrock.send(:anthropic_model?, model_double('us.amazon.nova-pro-v1:0', provider: 'bedrock'))) - .to be(false) - expect(bedrock.send(:anthropic_model?, - model_double('us.meta.llama3-1-405b-instruct-v1:0', provider: 'bedrock'))) - .to be(false) - end - - it 'returns true for ARN ids whose metadata shows Anthropic as provider' do - model = model_double(arn_id, metadata: { provider_name: 'Anthropic' }) - expect(bedrock.send(:anthropic_model?, model)).to be(true) - end - - it 'returns false and logs a warning for ARN ids without provider_name metadata' do - allow(RubyLLM.logger).to receive(:warn) - expect(bedrock.send(:anthropic_model?, model_double(arn_id))).to be(false) - expect(RubyLLM.logger).to have_received(:warn).with(/cannot verify.*Anthropic-backed/) - end - - it 'returns true for models whose provider field is "anthropic" (fallback)' do - model = model_double('unknown-model', provider: 'anthropic') - expect(bedrock.send(:anthropic_model?, model)).to be(true) - end - - it 'returns false for models with non-anthropic provider field and unknown prefix' do - model = model_double('unknown-model', provider: 'bedrock') - expect(bedrock.send(:anthropic_model?, model)).to be(false) - end - end end describe 'model id path encoding' do