diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index a62ec115c..04eeff92b 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def update params_to_update = captain_params - @current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models] - @current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features] + @current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models) + @current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features) @current_account.save! render json: preferences_payload @@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def merged_captain_models existing_models = @current_account.captain_models || {} - existing_models.merge(permitted_captain_models) + existing_models.merge(permitted_captain_models).compact_blank.presence end def merged_captain_features @@ -61,13 +61,16 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas def features_with_account_preferences preferences = Current.account.captain_preferences account_features = preferences[:features] || {} - account_models = preferences[:models] || {} Llm::Models.feature_keys.index_with do |feature_key| config = Llm::Models.feature_config(feature_key) + route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account) config.merge( enabled: account_features[feature_key] == true, - selected: account_models[feature_key] || config[:default] + model: route[:model], + selected: route[:model], + provider: route[:provider], + source: route[:source] ) end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 27ce587f7..59b99c37e 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params permitted_params = super - permitted_params[:limits] = permitted_params[:limits].to_h.compact + permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) + permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params end diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 9be674f11..b2683f2e0 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard # Add all_features last so it appears after manually_managed_features attributes[:all_features] = AccountFeaturesField + attributes[:captain_models] = CaptainModelOverridesField attributes else @@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[custom_attributes limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard attrs = %i[limits] attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? attrs << :all_features + attrs << :captain_models attrs else [] @@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - attrs = super + [limits: {}] + attrs = super + [limits: {}, captain_models: {}] # Add manually_managed_features to permitted attributes only for Chatwoot Cloud attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/models/concerns/captain_featurable.rb b/app/models/concerns/captain_featurable.rb index 2d99dd41f..16566eb25 100644 --- a/app/models/concerns/captain_featurable.rb +++ b/app/models/concerns/captain_featurable.rb @@ -4,6 +4,7 @@ module CaptainFeaturable extend ActiveSupport::Concern included do + before_validation :normalize_captain_models validate :validate_captain_models # Dynamically define accessor methods for each captain feature @@ -46,11 +47,27 @@ module CaptainFeaturable return if captain_models.blank? captain_models.each do |feature_key, model_name| - next if model_name.blank? + unless Llm::Models.feature?(feature_key) + errors.add(:captain_models, "'#{feature_key}' is not a known feature") + next + end + next if Llm::Models.valid_model_for?(feature_key, model_name) allowed_models = Llm::Models.models_for(feature_key) errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}") end end + + def normalize_captain_models + return unless captain_models.is_a?(Hash) + + normalized_models = captain_models.each_with_object({}) do |(feature_key, model_name), result| + next if model_name.blank? + + result[feature_key.to_s] = model_name.to_s + end + + self.captain_models = normalized_models.presence + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index c3672ef7b..22d3630af 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -574,6 +574,28 @@ en: ssl_status: custom_domain_not_configured: 'Custom domain is not configured' super_admin: + captain_model_overrides: + form: + helper_text: 'Leave a model blank to use the YAML default for that AI feature.' + use_default: 'Use default: %{model} (%{model_id})' + show: + summary: 'View model routing' + provider: 'Provider' + model: 'Model' + sources: + account_override: 'Account override' + default: 'Default' + features: + editor: 'Editor' + assistant: 'Assistant' + copilot: 'Copilot' + label_suggestion: 'Label suggestion' + document_faq_generation: 'Document FAQ generation' + help_center_article_generation: 'Help center article generation' + onboarding_content_generation: 'Onboarding content generation' + help_center_query_translation: 'Help center query translation' + audio_transcription: 'Audio transcription' + help_center_search: 'Help center search' push_diagnostics: user_not_found: 'User not found.' no_subscriptions_to_test: 'Select at least one subscription to test.' diff --git a/enterprise/app/fields/captain_model_overrides_field.rb b/enterprise/app/fields/captain_model_overrides_field.rb new file mode 100644 index 000000000..a8f3fe399 --- /dev/null +++ b/enterprise/app/fields/captain_model_overrides_field.rb @@ -0,0 +1,56 @@ +require 'administrate/field/base' + +class CaptainModelOverridesField < Administrate::Field::Base + def feature_rows + Llm::Models.feature_keys.map do |feature_key| + route = Llm::FeatureRouter.resolve(feature: feature_key, account: resource) + + { + key: feature_key, + name: feature_name(feature_key), + provider: provider_label(route[:provider]), + provider_id: route[:provider], + model: model_label(route[:model]), + model_id: route[:model], + default_model: model_label(default_model_id(feature_key)), + default_model_id: default_model_id(feature_key), + source: route[:source], + source_label: source_label(route[:source]), + selected_override: selected_override(feature_key), + options: model_options(feature_key) + } + end + end + + private + + def selected_override(feature_key) + resource.captain_models&.[](feature_key).presence + end + + def default_model_id(feature_key) + Llm::Models.default_model_for(feature_key) + end + + def model_options(feature_key) + Llm::Models.feature_config(feature_key)[:models].map do |model| + [model[:display_name] || model[:id], model[:id]] + end + end + + def model_label(model_id) + Llm::Models.model_config(model_id)&.dig('display_name') || model_id + end + + def provider_label(provider_id) + Llm::Models.providers.dig(provider_id, 'display_name') || provider_id + end + + def feature_name(feature_key) + I18n.t("super_admin.captain_model_overrides.features.#{feature_key}", default: feature_key.humanize) + end + + def source_label(source) + I18n.t("super_admin.captain_model_overrides.sources.#{source}") + end +end diff --git a/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb b/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb new file mode 100644 index 000000000..0420ab09a --- /dev/null +++ b/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb @@ -0,0 +1,27 @@ +
<%= t('super_admin.captain_model_overrides.form.helper_text') %>
+ +