From d57bb645cb7d2be64c38be6b306f606c0ceae25c Mon Sep 17 00:00:00 2001 From: Ryan O'Donnell Date: Wed, 18 Feb 2026 16:20:25 -0500 Subject: [PATCH 1/2] Add Bedrock structured output support Extend the Bedrock Converse API provider to support structured output via outputConfig.textFormat, building on #608's Anthropic implementation. Key differences from the direct Anthropic API: schema must be a JSON string (not a Hash), nested under structure.jsonSchema with a required name field, and uses camelCase Converse API naming. Co-Authored-By: Claude Opus 4.6 --- lib/ruby_llm/providers/bedrock/chat.rb | 23 ++++++ lib/ruby_llm/providers/bedrock/models.rb | 16 ++++ ...n_schema_and_returns_structured_output.yml | 45 +++++++++++ spec/ruby_llm/chat_schema_spec.rb | 19 ++++- spec/ruby_llm/providers/bedrock/chat_spec.rb | 77 +++++++++++++++++++ .../ruby_llm/providers/bedrock/models_spec.rb | 23 ++++++ 6 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_accepts_a_json_schema_and_returns_structured_output.yml create mode 100644 spec/ruby_llm/providers/bedrock/chat_spec.rb create mode 100644 spec/ruby_llm/providers/bedrock/models_spec.rb diff --git a/lib/ruby_llm/providers/bedrock/chat.rb b/lib/ruby_llm/providers/bedrock/chat.rb index 923b1b37b..36a9c4370 100644 --- a/lib/ruby_llm/providers/bedrock/chat.rb +++ b/lib/ruby_llm/providers/bedrock/chat.rb @@ -34,6 +34,9 @@ def render_payload(messages, tools:, temperature:, model:, stream: false, schema additional_fields = render_additional_model_request_fields(thinking) payload[:additionalModelRequestFields] = additional_fields if additional_fields + output_config = build_output_config(schema) + payload[:outputConfig] = output_config if output_config + payload end @@ -238,6 +241,26 @@ def render_additional_model_request_fields(thinking) fields.empty? ? nil : fields end + def build_output_config(schema) + return nil unless schema + + cleaned = RubyLLM::Utils.deep_dup(schema) + cleaned.delete(:strict) + cleaned.delete('strict') + + { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + schema: JSON.generate(cleaned), + name: 'response' + } + } + } + } + end + def render_reasoning_fields(thinking) return nil unless thinking&.enabled? diff --git a/lib/ruby_llm/providers/bedrock/models.rb b/lib/ruby_llm/providers/bedrock/models.rb index cb484f4e3..19857df7d 100644 --- a/lib/ruby_llm/providers/bedrock/models.rb +++ b/lib/ruby_llm/providers/bedrock/models.rb @@ -100,10 +100,26 @@ def parse_capabilities(model_data) converse = model_data['converse'] || {} capabilities << 'function_calling' if converse.is_a?(Hash) capabilities << 'reasoning' if converse.dig('reasoningSupported', 'embedded') + capabilities << 'structured_output' if supports_structured_output?(model_data['modelId']) capabilities end + # Structured output supported on Claude 4.5+ and assumed for future major versions. + # Bedrock IDs look like: us.anthropic.claude-haiku-4-5-20251001-v1:0 + # Must handle optional region prefix (us./eu./global.) and anthropic. prefix. + def supports_structured_output?(model_id) + return false unless model_id + + normalized = model_id.sub(/\A(?:#{REGION_PREFIXES.join('|')})\./, '').delete_prefix('anthropic.') + match = normalized.match(/claude-(?:opus|sonnet|haiku)-(\d+)-(\d{1,2})(?:\b|-)/) + return false unless match + + major = match[1].to_i + minor = match[2].to_i + major > 4 || (major == 4 && minor >= 5) + end + def reasoning_embedded?(model) metadata = RubyLLM::Utils.deep_symbolize_keys(model.metadata || {}) converse = metadata[:converse] || {} diff --git a/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_accepts_a_json_schema_and_returns_structured_output.yml b/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_accepts_a_json_schema_and_returns_structured_output.yml new file mode 100644 index 000000000..655158697 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_accepts_a_json_schema_and_returns_structured_output.yml @@ -0,0 +1,45 @@ +--- +http_interactions: +- request: + method: post + uri: https://bedrock-runtime..amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/converse + body: + encoding: UTF-8 + string: '{"messages":[{"role":"user","content":[{"text":"Generate a person named + Bob who is 35 years old"}]}],"inferenceConfig":{},"outputConfig":{"textFormat":{"type":"json_schema","structure":{"jsonSchema":{"schema":"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"}},\"required\":[\"name\",\"age\"],\"additionalProperties\":false}","name":"response"}}}}}' + headers: + User-Agent: + - Faraday v2.14.0 + X-Amz-Date: + - 20260218T210941Z + X-Amz-Content-Sha256: + - 4eab183f5911606437629401543d8c578dcfc35a9b43737c8afa5f83f4b94fea + Authorization: + - AWS4-HMAC-SHA256 Credential=/20260218//bedrock/aws4_request, + SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=5c82b3e9dec1bb118269c2d3c581c5b591e7893f9bc0530ad64579bf082c922c + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 18 Feb 2026 21:09:44 GMT + Content-Type: + - application/json + Content-Length: + - '344' + Connection: + - keep-alive + X-Amzn-Requestid: + - bfc1e656-c5dd-4d36-b3bd-3e9e9c1c056d + body: + encoding: UTF-8 + string: '{"metrics":{"latencyMs":2299},"output":{"message":{"content":[{"text":"{\"name\":\"Bob\",\"age\":35}"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"cacheReadInputTokenCount":0,"cacheReadInputTokens":0,"cacheWriteInputTokenCount":0,"cacheWriteInputTokens":0,"inputTokens":181,"outputTokens":12,"serverToolUsage":{},"totalTokens":193}}' + recorded_at: Wed, 18 Feb 2026 21:09:44 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/chat_schema_spec.rb b/spec/ruby_llm/chat_schema_spec.rb index 2ba4d62c4..2d0921699 100644 --- a/spec/ruby_llm/chat_schema_spec.rb +++ b/spec/ruby_llm/chat_schema_spec.rb @@ -66,6 +66,23 @@ end end + # Test Bedrock provider with Claude model + context 'with bedrock/claude-haiku-4-5' do + let(:chat) { RubyLLM.chat(model: 'claude-haiku-4-5', provider: :bedrock) } + + it 'accepts a JSON schema and returns structured output' do + skip 'Model does not support structured output' unless chat.model.structured_output? + + response = chat + .with_schema(person_schema) + .ask('Generate a person named Bob who is 35 years old') + + expect(response.content).to be_a(Hash) + expect(response.content['name']).to eq('Bob') + expect(response.content['age']).to eq(35) + end + end + # Test Gemini provider separately due to different schema format CHAT_MODELS.select { |model_info| model_info[:provider] == :gemini }.each do |model_info| model = model_info[:model] @@ -123,7 +140,7 @@ end test_model = CHAT_MODELS.find do |model_info| - %i[openai gemini].include?(model_info[:provider]) + %i[openai gemini bedrock].include?(model_info[:provider]) end if test_model diff --git a/spec/ruby_llm/providers/bedrock/chat_spec.rb b/spec/ruby_llm/providers/bedrock/chat_spec.rb new file mode 100644 index 000000000..f7ff162b8 --- /dev/null +++ b/spec/ruby_llm/providers/bedrock/chat_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::Bedrock::Chat do + describe '.render_payload' do + let(:model) do + instance_double(RubyLLM::Model::Info, + id: 'anthropic.claude-haiku-4-5-20251001-v1:0', + max_tokens: nil, + metadata: {}) + end + + let(:base_args) do + { + tools: {}, + temperature: nil, + model: model, + stream: false + } + end + + def render_payload(messages = [], **overrides) + described_class.render_payload(messages, **base_args, **overrides) + end + + context 'when schema is provided' do + let(:schema) do + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + strict: true + } + end + + it 'includes outputConfig with stringified schema' do + payload = render_payload(schema: schema) + + output_config = payload[:outputConfig] + expect(output_config).not_to be_nil + expect(output_config[:textFormat][:type]).to eq('json_schema') + + json_schema = output_config[:textFormat][:structure][:jsonSchema] + expect(json_schema[:name]).to eq('response') + expect(json_schema[:schema]).to be_a(String) + + parsed = JSON.parse(json_schema[:schema]) + expect(parsed['type']).to eq('object') + expect(parsed['properties']).to eq({ 'name' => { 'type' => 'string' } }) + end + + it 'strips :strict from the schema' do + payload = render_payload(schema: schema) + + json_schema = payload[:outputConfig][:textFormat][:structure][:jsonSchema] + parsed = JSON.parse(json_schema[:schema]) + expect(parsed).not_to have_key('strict') + expect(parsed).not_to have_key(:strict) + end + + it 'does not mutate the original schema' do + original = schema.dup + render_payload(schema: schema) + expect(schema).to eq(original) + end + end + + context 'when schema is nil' do + it 'does not include outputConfig' do + payload = render_payload(schema: nil) + expect(payload).not_to have_key(:outputConfig) + end + end + end +end diff --git a/spec/ruby_llm/providers/bedrock/models_spec.rb b/spec/ruby_llm/providers/bedrock/models_spec.rb new file mode 100644 index 000000000..5b399ea78 --- /dev/null +++ b/spec/ruby_llm/providers/bedrock/models_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::Bedrock::Models do + describe '.supports_structured_output?' do + { + 'anthropic.claude-haiku-4-5-20251001-v1:0' => true, + 'anthropic.claude-sonnet-4-5-20250929-v1:0' => true, + 'anthropic.claude-opus-4-5-20250514-v1:0' => true, + 'us.anthropic.claude-opus-4-6-v1' => true, + 'eu.anthropic.claude-haiku-4-5-20251001-v1:0' => true, + 'anthropic.claude-opus-4-20250514-v1:0' => false, + 'anthropic.claude-3-5-sonnet-20241022-v2:0' => false, + 'amazon.nova-2-lite-v1:0' => false, + nil => false + }.each do |model_id, expected| + it "returns #{expected} for #{model_id.inspect}" do + expect(described_class.supports_structured_output?(model_id)).to eq(expected) + end + end + end +end From bf91976c40a4ac73b6bbc7799e79ea78804942dd Mon Sep 17 00:00:00 2001 From: Carmine Paolino Date: Sun, 1 Mar 2026 15:08:51 +0100 Subject: [PATCH 2/2] Align Bedrock schema tests and handle global model IDs --- lib/ruby_llm/providers/bedrock/models.rb | 2 +- ...oving_schema_with_nil_mid-conversation.yml | 103 ++++++++++++++++++ spec/ruby_llm/chat_schema_spec.rb | 19 +--- .../ruby_llm/providers/bedrock/models_spec.rb | 1 + spec/support/models_to_test.rb | 7 ++ 5 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_allows_removing_schema_with_nil_mid-conversation.yml diff --git a/lib/ruby_llm/providers/bedrock/models.rb b/lib/ruby_llm/providers/bedrock/models.rb index 19857df7d..e38d8b224 100644 --- a/lib/ruby_llm/providers/bedrock/models.rb +++ b/lib/ruby_llm/providers/bedrock/models.rb @@ -7,7 +7,7 @@ class Bedrock module Models module_function - REGION_PREFIXES = %w[us eu ap sa ca me af il].freeze + REGION_PREFIXES = %w[global us eu ap sa ca me af il].freeze def models_api_base "https://bedrock.#{bedrock_region}.amazonaws.com" diff --git a/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_allows_removing_schema_with_nil_mid-conversation.yml b/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_allows_removing_schema_with_nil_mid-conversation.yml new file mode 100644 index 000000000..76509b17e --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_with_schema_with_bedrock_claude-haiku-4-5_allows_removing_schema_with_nil_mid-conversation.yml @@ -0,0 +1,103 @@ +--- +http_interactions: +- request: + method: post + uri: https://bedrock-runtime..amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/converse + body: + encoding: UTF-8 + string: '{"messages":[{"role":"user","content":[{"text":"Generate a person named + Bob"}]}],"inferenceConfig":{},"outputConfig":{"textFormat":{"type":"json_schema","structure":{"jsonSchema":{"schema":"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"}},\"required\":[\"name\",\"age\"],\"additionalProperties\":false}","name":"response"}}}}}' + headers: + User-Agent: + - Faraday v2.14.1 + X-Amz-Date: + - 20260301T140806Z + X-Amz-Content-Sha256: + - af62f0aa22d1ee9e206e655ec402d6baf3a7468f410418a88a9815c11975716d + Authorization: + - AWS4-HMAC-SHA256 Credential=/20260301//bedrock/aws4_request, + SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=8866daba06b03dad84885fe168fa0c39f2c4f288661a76ca1243c792388bdb60 + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 01 Mar 2026 14:08:07 GMT + Content-Type: + - application/json + Content-Length: + - '343' + Connection: + - keep-alive + X-Amzn-Requestid: + - 399f0c21-8df2-4bb8-8b96-822cfdbf4efc + body: + encoding: UTF-8 + string: '{"metrics":{"latencyMs":795},"output":{"message":{"content":[{"text":"{\"name\":\"Bob\",\"age\":30}"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"cacheReadInputTokenCount":0,"cacheReadInputTokens":0,"cacheWriteInputTokenCount":0,"cacheWriteInputTokens":0,"inputTokens":174,"outputTokens":12,"serverToolUsage":{},"totalTokens":186}}' + recorded_at: Sun, 01 Mar 2026 14:08:07 GMT +- request: + method: post + uri: https://bedrock-runtime..amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/converse + body: + encoding: UTF-8 + string: '{"messages":[{"role":"user","content":[{"text":"Generate a person named + Bob"}]},{"role":"assistant","content":[{"text":"{\"name\":\"Bob\",\"age\":30}"}]},{"role":"user","content":[{"text":"Now + just tell me about Ruby"}]}],"inferenceConfig":{}}' + headers: + User-Agent: + - Faraday v2.14.1 + X-Amz-Date: + - 20260301T140807Z + X-Amz-Content-Sha256: + - 4192f814dde366ba7862dfc154dbdb0ec0f4807aca4d4fb3ea390a961942545a + Authorization: + - AWS4-HMAC-SHA256 Credential=/20260301//bedrock/aws4_request, + SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=fbafd025fbdbecacfbae31177bc051f9c5365766af384a748a387fea5e65f7bd + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Sun, 01 Mar 2026 14:08:11 GMT + Content-Type: + - application/json + Content-Length: + - '1453' + Connection: + - keep-alive + X-Amzn-Requestid: + - d13943cd-0da1-4948-ac9c-333c805b17dc + body: + encoding: UTF-8 + string: '{"metrics":{"latencyMs":2750},"output":{"message":{"content":[{"text":"Ruby + is a dynamic, open-source programming language known for its simplicity and + elegance. Here are some key points about it:\n\n**Key Features:**\n- **Simple + syntax** - Easy to read and write, with a focus on programmer happiness\n- + **Object-oriented** - Everything is an object\n- **Dynamic typing** - Types + are determined at runtime\n- **Interpreted language** - Code is executed directly + without compilation\n\n**Common Uses:**\n- Web development (especially with + Ruby on Rails framework)\n- Scripting and automation\n- Data analysis\n- DevOps + tools\n\n**Ruby on Rails:**\n- A popular web framework that makes web development + faster\n- Follows conventions over configuration\n- Used by many startups + and companies\n\n**Advantages:**\n- Beginner-friendly\n- Strong community + and lots of libraries (gems)\n- High productivity\n- Good for rapid development\n\n**Disadvantages:**\n- + Slower performance compared to compiled languages\n- Less suitable for large-scale + systems\n- Smaller job market than some other languages\n\nRuby is often recommended + as a great first programming language due to its readable syntax and helpful + community!"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"cacheReadInputTokenCount":0,"cacheReadInputTokens":0,"cacheWriteInputTokenCount":0,"cacheWriteInputTokens":0,"inputTokens":33,"outputTokens":246,"serverToolUsage":{},"totalTokens":279}}' + recorded_at: Sun, 01 Mar 2026 14:08:11 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/ruby_llm/chat_schema_spec.rb b/spec/ruby_llm/chat_schema_spec.rb index 316ae6369..9668d5001 100644 --- a/spec/ruby_llm/chat_schema_spec.rb +++ b/spec/ruby_llm/chat_schema_spec.rb @@ -20,7 +20,7 @@ # Test providers that support structured output with JSON schema # Note: Only test models that have json_schema support, not just json_object - CHAT_MODELS.select { |model_info| %i[openai anthropic].include?(model_info[:provider]) }.each do |model_info| + STRUCTURED_OUTPUT_MODELS.each do |model_info| model = model_info[:model] provider = model_info[:provider] @@ -68,23 +68,6 @@ end end - # Test Bedrock provider with Claude model - context 'with bedrock/claude-haiku-4-5' do - let(:chat) { RubyLLM.chat(model: 'claude-haiku-4-5', provider: :bedrock) } - - it 'accepts a JSON schema and returns structured output' do - skip 'Model does not support structured output' unless chat.model.structured_output? - - response = chat - .with_schema(person_schema) - .ask('Generate a person named Bob who is 35 years old') - - expect(response.content).to be_a(Hash) - expect(response.content['name']).to eq('Bob') - expect(response.content['age']).to eq(35) - end - end - # Test Gemini provider separately due to different schema format CHAT_MODELS.select { |model_info| model_info[:provider] == :gemini }.each do |model_info| model = model_info[:model] diff --git a/spec/ruby_llm/providers/bedrock/models_spec.rb b/spec/ruby_llm/providers/bedrock/models_spec.rb index 5b399ea78..24033f60f 100644 --- a/spec/ruby_llm/providers/bedrock/models_spec.rb +++ b/spec/ruby_llm/providers/bedrock/models_spec.rb @@ -10,6 +10,7 @@ 'anthropic.claude-opus-4-5-20250514-v1:0' => true, 'us.anthropic.claude-opus-4-6-v1' => true, 'eu.anthropic.claude-haiku-4-5-20251001-v1:0' => true, + 'global.anthropic.claude-haiku-4-5-20251001-v1:0' => true, 'anthropic.claude-opus-4-20250514-v1:0' => false, 'anthropic.claude-3-5-sonnet-20241022-v2:0' => false, 'amazon.nova-2-lite-v1:0' => false, diff --git a/spec/support/models_to_test.rb b/spec/support/models_to_test.rb index c197101e1..43ddba3d2 100644 --- a/spec/support/models_to_test.rb +++ b/spec/support/models_to_test.rb @@ -24,6 +24,13 @@ def filter_local_providers(models) ].freeze CHAT_MODELS = filter_local_providers(chat_models).freeze +structured_output_models = [ + { provider: :openai, model: 'gpt-5-nano' }, + { provider: :anthropic, model: 'claude-haiku-4-5' }, + { provider: :bedrock, model: 'claude-haiku-4-5' } +] +STRUCTURED_OUTPUT_MODELS = structured_output_models.freeze + thinking_models = [ { provider: :anthropic, model: 'claude-haiku-4-5' }, { provider: :azure, model: 'Kimi-K2.5' },