From 8b977b35a80b096fda3b86ed0b04cb7662b414a6 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Thu, 25 Jun 2026 17:00:07 +0530 Subject: [PATCH] feat: Add LLM feature router (1/6) (#14839) ## Description Adds the foundation for feature-specific LLM model routing so Captain AI features can resolve their effective provider/model from code defaults and account-level overrides. This fixes the provider metadata key in `config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing `CaptainFeaturable` model defaults through the shared resolver. Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - `bundle exec rspec spec/lib/llm/models_spec.rb spec/lib/llm/feature_router_spec.rb spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0 failures - `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb spec/lib/llm/feature_router_spec.rb spec/models/concerns/captain_featurable_spec.rb` - no offenses - `bundle exec ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml'); abort('missing providers') unless config['providers']; abort('missing models') unless config['models']; abort('missing features') unless config['features']; puts 'llm.yml ok'"` - `git diff --check` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/models/concerns/captain_featurable.rb | 10 +--- config/llm.yml | 2 +- lib/llm/feature_router.rb | 29 +++++++++++ lib/llm/models.rb | 28 ++++++++--- spec/lib/llm/feature_router_spec.rb | 60 +++++++++++++++++++++++ spec/lib/llm/models_spec.rb | 56 +++++++++++++++++++++ 6 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 lib/llm/feature_router.rb create mode 100644 spec/lib/llm/feature_router_spec.rb create mode 100644 spec/lib/llm/models_spec.rb diff --git a/app/models/concerns/captain_featurable.rb b/app/models/concerns/captain_featurable.rb index af73fded3..2d99dd41f 100644 --- a/app/models/concerns/captain_featurable.rb +++ b/app/models/concerns/captain_featurable.rb @@ -30,14 +30,8 @@ module CaptainFeaturable private def captain_models_with_defaults - stored_models = captain_models || {} - Llm::Models.feature_keys.each_with_object({}) do |feature_key, result| - stored_value = stored_models[feature_key] - result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value) - stored_value - else - Llm::Models.default_model_for(feature_key) - end + Llm::Models.feature_keys.index_with do |feature_key| + Llm::FeatureRouter.resolve(feature: feature_key, account: self)[:model] end end diff --git a/config/llm.yml b/config/llm.yml index 1442c83f0..204b0a1b4 100644 --- a/config/llm.yml +++ b/config/llm.yml @@ -1,4 +1,4 @@ -aproviders: +providers: openai: display_name: 'OpenAI' anthropic: diff --git a/lib/llm/feature_router.rb b/lib/llm/feature_router.rb new file mode 100644 index 000000000..da0aa56e9 --- /dev/null +++ b/lib/llm/feature_router.rb @@ -0,0 +1,29 @@ +module Llm::FeatureRouter + class UnknownFeatureError < StandardError; end + + class << self + def resolve(feature:, account: nil) + feature_key = feature.to_s + raise UnknownFeatureError, "Unknown LLM feature: #{feature_key}" unless Llm::Models.feature?(feature_key) + + model = account_model_override(account, feature_key) + source = model.present? ? :account_override : :default + model ||= Llm::Models.default_model_for(feature_key) + + { + feature: feature_key, + provider: Llm::Models.provider_for(model), + model: model, + source: source + } + end + + private + + def account_model_override(account, feature_key) + model = account&.captain_models&.[](feature_key).presence + return unless model + return model if Llm::Models.valid_model_for?(feature_key, model) + end + end +end diff --git a/lib/llm/models.rb b/lib/llm/models.rb index 010742ff4..896014262 100644 --- a/lib/llm/models.rb +++ b/lib/llm/models.rb @@ -2,30 +2,42 @@ module Llm::Models CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze class << self - def providers = CONFIG['providers'] - def models = CONFIG['models'] - def features = CONFIG['features'] - def feature_keys = CONFIG['features'].keys + def providers = CONFIG.fetch('providers') + def models = CONFIG.fetch('models') + def features = CONFIG.fetch('features') + def feature_keys = features.keys + + def feature?(feature) + features.key?(feature.to_s) + end def default_model_for(feature) - CONFIG.dig('features', feature.to_s, 'default') + features.dig(feature.to_s, 'default') end def models_for(feature) - CONFIG.dig('features', feature.to_s, 'models') || [] + features.dig(feature.to_s, 'models') || [] end def valid_model_for?(feature, model_name) models_for(feature).include?(model_name.to_s) end + def model_config(model_name) + models[model_name.to_s] + end + + def provider_for(model_name) + model_config(model_name)&.dig('provider') + end + def feature_config(feature_key) feature = features[feature_key.to_s] return nil unless feature { - models: feature['models'].map do |model_name| - model = models[model_name] + models: models_for(feature_key).map do |model_name| + model = model_config(model_name) { id: model_name, display_name: model['display_name'], diff --git a/spec/lib/llm/feature_router_spec.rb b/spec/lib/llm/feature_router_spec.rb new file mode 100644 index 000000000..e0eb4afa0 --- /dev/null +++ b/spec/lib/llm/feature_router_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Llm::FeatureRouter do + let(:account) { create(:account) } + + describe '.resolve' do + it 'returns the feature default without an account' do + resolved = described_class.resolve(feature: 'editor') + + expect(resolved).to eq( + feature: 'editor', + provider: 'openai', + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'uses a valid account model override' do + account.update!(captain_models: { 'editor' => 'gpt-4.1' }) + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + feature: 'editor', + provider: 'openai', + model: 'gpt-4.1', + source: :account_override + ) + end + + it 'falls back to the feature default when the account override is invalid' do + account.captain_models = { 'editor' => 'invalid-model' } + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'falls back to the feature default when the account override is blank' do + account.update!(captain_models: { 'editor' => '' }) + + resolved = described_class.resolve(feature: 'editor', account: account) + + expect(resolved).to include( + model: 'gpt-4.1-mini', + source: :default + ) + end + + it 'raises for unknown features' do + expect { described_class.resolve(feature: 'unknown_feature') } + .to raise_error(described_class::UnknownFeatureError, 'Unknown LLM feature: unknown_feature') + end + end +end diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb new file mode 100644 index 000000000..f93df20fb --- /dev/null +++ b/spec/lib/llm/models_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Llm::Models do + describe '.providers' do + it 'loads provider metadata from the config' do + expect(described_class.providers).to include( + 'openai' => include('display_name' => 'OpenAI') + ) + end + end + + describe '.features' do + it 'keeps every feature default in the allowed model list' do + described_class.features.each do |feature_key, config| + expect(config['models']).to include(config['default']), "#{feature_key} default model must be allowed" + end + end + + it 'references existing models from every feature' do + described_class.features.each do |feature_key, config| + missing_models = config['models'].reject { |model_name| described_class.models.key?(model_name) } + + expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}" + end + end + end + + describe '.models' do + it 'references existing providers from every model' do + missing_providers = described_class.models.filter_map do |model_name, config| + provider = config['provider'] + next if described_class.providers.key?(provider) + + "#{model_name}: #{provider}" + end + + expect(missing_providers).to be_empty + end + end + + describe '.feature_config' do + it 'returns model metadata for a feature' do + config = described_class.feature_config('editor') + + expect(config[:default]).to eq('gpt-4.1-mini') + expect(config[:models].first).to include( + id: 'gpt-4.1-mini', + display_name: 'GPT-4.1 Mini', + provider: 'openai', + credit_multiplier: 1 + ) + end + end +end