From 85a6e886cf2aac5b52dd9af11a9363491cbb20ba Mon Sep 17 00:00:00 2001 From: Ryan O'Donnell Date: Wed, 18 Mar 2026 09:14:35 -0700 Subject: [PATCH 1/3] Add default_providers configuration for model-to-provider routing Adds a `default_providers` config option that routes models to specific providers by model ID prefix, eliminating the need to pass `provider:` on every call. Longer (more specific) keys take precedence. Closes #686 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ruby_llm/configuration.rb | 2 + lib/ruby_llm/models.rb | 12 ++ spec/ruby_llm/configuration_spec.rb | 1 + .../ruby_llm/models_default_providers_spec.rb | 119 ++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 spec/ruby_llm/models_default_providers_spec.rb diff --git a/lib/ruby_llm/configuration.rb b/lib/ruby_llm/configuration.rb index de6202686..2cce29fa4 100644 --- a/lib/ruby_llm/configuration.rb +++ b/lib/ruby_llm/configuration.rb @@ -38,6 +38,8 @@ def defaults = @defaults ||= {} option :default_image_model, 'gpt-image-1.5' option :default_transcription_model, 'whisper-1' + option :default_providers, -> { {} } + option :model_registry_file, -> { File.expand_path('models.json', __dir__) } option :model_registry_class, 'Model' diff --git a/lib/ruby_llm/models.rb b/lib/ruby_llm/models.rb index be5d59d87..c74988398 100644 --- a/lib/ruby_llm/models.rb +++ b/lib/ruby_llm/models.rb @@ -105,6 +105,7 @@ def fetch_from_providers(remote_only: true) def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity config ||= RubyLLM.config + provider ||= resolve_default_provider(model_id, config) provider_class = provider ? Provider.providers[provider.to_sym] : nil if provider_class @@ -136,6 +137,17 @@ def resolve(model_id, provider: nil, assume_exists: false, config: nil) # ruboco [model, provider_instance] end + def resolve_default_provider(model_id, config) + default_providers = config.default_providers + return nil if default_providers.nil? || default_providers.empty? + + model_id_str = model_id.to_s + best_key = default_providers.keys + .select { |key| model_id_str.start_with?(key.to_s) } + .max_by { |key| key.to_s.length } + default_providers[best_key] if best_key + end + def method_missing(method, ...) if instance.respond_to?(method) instance.send(method, ...) diff --git a/spec/ruby_llm/configuration_spec.rb b/spec/ruby_llm/configuration_spec.rb index 4a68fc51c..f617b8095 100644 --- a/spec/ruby_llm/configuration_spec.rb +++ b/spec/ruby_llm/configuration_spec.rb @@ -14,6 +14,7 @@ expect(config.retry_interval).to eq(0.1) expect(config.retry_backoff_factor).to eq(2) expect(config.retry_interval_randomness).to eq(0.5) + expect(config.default_providers).to eq({}) end it 'exposes a discoverable options API' do diff --git a/spec/ruby_llm/models_default_providers_spec.rb b/spec/ruby_llm/models_default_providers_spec.rb new file mode 100644 index 000000000..3d2143137 --- /dev/null +++ b/spec/ruby_llm/models_default_providers_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Models do + include_context 'with configured RubyLLM' + + let(:anthropic_model) do + RubyLLM::Model::Info.new( + id: 'claude-sonnet-4-5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + family: 'claude-sonnet' + ) + end + + let(:bedrock_model) do + RubyLLM::Model::Info.new( + id: 'claude-sonnet-4-5', + name: 'Claude Sonnet 4.5', + provider: 'bedrock', + family: 'claude-sonnet' + ) + end + + let(:haiku_anthropic) do + RubyLLM::Model::Info.new( + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + provider: 'anthropic', + family: 'claude-haiku' + ) + end + + let(:haiku_bedrock) do + RubyLLM::Model::Info.new( + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + provider: 'bedrock', + family: 'claude-haiku' + ) + end + + after do + described_class.instance_variable_set(:@instance, nil) + RubyLLM.configure do |config| + config.default_providers = {} + end + end + + describe 'default_providers' do + it 'routes models to the configured provider by prefix match' do + models = described_class.new([anthropic_model, bedrock_model]) + + RubyLLM.configure do |config| + config.default_providers = { 'claude' => :bedrock } + end + + model, provider = models.resolve('claude-sonnet-4-5') + expect(model.provider).to eq('bedrock') + expect(provider).to be_a(RubyLLM::Provider) + end + + it 'prefers longer (more specific) keys' do + models = described_class.new([haiku_anthropic, haiku_bedrock, anthropic_model, bedrock_model]) + + RubyLLM.configure do |config| + config.default_providers = { + 'claude' => :bedrock, + 'claude-haiku' => :anthropic + } + end + + # claude-haiku matches the longer key → anthropic + model, _provider = models.resolve('claude-haiku-4-5') + expect(model.provider).to eq('anthropic') + + # claude-sonnet only matches 'claude' → bedrock + model, _provider = models.resolve('claude-sonnet-4-5') + expect(model.provider).to eq('bedrock') + end + + it 'does not apply when an explicit provider: is passed' do + models = described_class.new([anthropic_model, bedrock_model]) + + RubyLLM.configure do |config| + config.default_providers = { 'claude' => :bedrock } + end + + model, _provider = models.resolve('claude-sonnet-4-5', provider: :anthropic) + expect(model.provider).to eq('anthropic') + end + + it 'falls through to normal resolution when no prefix matches' do + models = described_class.new([anthropic_model, bedrock_model]) + + RubyLLM.configure do |config| + config.default_providers = { 'gpt' => :azure } + end + + # 'claude-sonnet-4-5' doesn't match 'gpt', so default_providers is skipped + # and normal PROVIDER_PREFERENCE applies (anthropic before bedrock) + model, _provider = models.resolve('claude-sonnet-4-5') + expect(model.provider).to eq('anthropic') + end + + it 'works with an empty hash (no-op)' do + models = described_class.new([anthropic_model, bedrock_model]) + + RubyLLM.configure do |config| + config.default_providers = {} + end + + # Falls through to default PROVIDER_PREFERENCE (anthropic before bedrock) + model, _provider = models.resolve('claude-sonnet-4-5') + expect(model.provider).to eq('anthropic') + end + end +end From abd0302ebcf72e28ff40d4c451368ba288e1a64d Mon Sep 17 00:00:00 2001 From: Ryan O'Donnell Date: Wed, 18 Mar 2026 09:31:48 -0700 Subject: [PATCH 2/3] Remove redundant empty hash test Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ruby_llm/models_default_providers_spec.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/spec/ruby_llm/models_default_providers_spec.rb b/spec/ruby_llm/models_default_providers_spec.rb index 3d2143137..c37651261 100644 --- a/spec/ruby_llm/models_default_providers_spec.rb +++ b/spec/ruby_llm/models_default_providers_spec.rb @@ -103,17 +103,5 @@ model, _provider = models.resolve('claude-sonnet-4-5') expect(model.provider).to eq('anthropic') end - - it 'works with an empty hash (no-op)' do - models = described_class.new([anthropic_model, bedrock_model]) - - RubyLLM.configure do |config| - config.default_providers = {} - end - - # Falls through to default PROVIDER_PREFERENCE (anthropic before bedrock) - model, _provider = models.resolve('claude-sonnet-4-5') - expect(model.provider).to eq('anthropic') - end end end From a7b3b1fe2f7baed5fdc08572ca75414a08cc3f52 Mon Sep 17 00:00:00 2001 From: Ryan O'Donnell Date: Wed, 18 Mar 2026 09:44:30 -0700 Subject: [PATCH 3/3] Add default_providers documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/_getting_started/configuration.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/_getting_started/configuration.md b/docs/_getting_started/configuration.md index b5686cb2e..244434fa4 100644 --- a/docs/_getting_started/configuration.md +++ b/docs/_getting_started/configuration.md @@ -200,6 +200,26 @@ Defaults if not configured: - Embeddings: `{{ site.models.default_embedding }}` - Images: `{{ site.models.default_image }}` +## Default Providers + +Route models to specific providers by default, so you don't have to pass `provider:` on every call: + +```ruby +RubyLLM.configure do |config| + config.default_providers = { + 'claude' => :bedrock, # all claude models route to bedrock + 'claude-haiku' => :anthropic, # except haiku, which goes direct to anthropic + 'gpt' => :azure, # all gpt models route to azure + 'gemini' => :vertexai, # all gemini models route to vertex ai + 'claude-sonnet-4-5' => :openrouter, # one specific model via openrouter + } +end +``` + +Keys are matched against the start of the model ID. When multiple keys match, the longest (most specific) one wins. Passing an explicit `provider:` argument always overrides `default_providers`. + +This applies to all model types: chat, embeddings, images, transcription, and moderation. + ## Model Registry File By default, RubyLLM reads model information from the bundled `models.json` file. If your gem directory is read-only, you can configure a writable location: @@ -513,6 +533,9 @@ RubyLLM.configure do |config| config.default_moderation_model = String config.default_transcription_model = String + # Default Providers + config.default_providers = Hash # Route models to providers by model ID prefix + # Model Registry config.model_registry_file = String # Path to model registry JSON file (v1.9.0+) config.model_registry_class = String