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