From c81055ea58269c7b917872d3bd009722c97530ef Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 25 May 2026 16:54:22 +0200 Subject: [PATCH] feat(ai): self-host settings UI for Anthropic provider (5/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Anthropic panel and the install-wide LLM provider selector to the self-hosting settings page, plus a shared data-retention disclosure that covers both OpenAI and Anthropic. - New _llm_provider_selector partial: select for Setting.llm_provider (openai | anthropic), respects the LLM_PROVIDER env var (disables the control + shows the "configured through environment variables" hint when set, mirroring the existing OpenAI panel behaviour), and renders a compact data-handling block with one-line retention statements for each provider. - New _anthropic_settings partial mirrors _openai_settings exactly: password-field for the API key with **** redaction, optional base_url (for AWS Bedrock / GCP Vertex), optional default model. All three fields disable when their ENV var is set. - show.html.erb renders provider selector + OpenAI panel + Anthropic panel under the same "General" section so users can configure either (or both) without switching pages. - Settings::HostingsController#update now permits and persists anthropic_access_token (ignoring the **** placeholder, same pattern as OpenAI), anthropic_base_url, anthropic_model, and llm_provider (validated against %w[openai anthropic]). On Setting::ValidationError the rescue branch preserves anthropic_base_url / anthropic_model input so the form re-renders with the user's typed values intact — parity with the issue #1824 fix for OpenAI. - Locale keys added under settings.hostings.{llm_provider_selector, anthropic_settings}. Tests cover token update + placeholder redaction, base_url + model update, llm_provider switch to anthropic, and rejection of unknown provider values. The existing GET render test still passes, exercising all three new partials. Closes the 5/5 Anthropic series stacked on #1986. --- .../settings/hostings_controller.rb | 26 ++++++++- .../hostings/_anthropic_settings.html.erb | 53 +++++++++++++++++++ .../hostings/_llm_provider_selector.html.erb | 38 +++++++++++++ app/views/settings/hostings/show.html.erb | 2 + config/locales/views/settings/hostings/en.yml | 22 ++++++++ .../settings/hostings_controller_test.rb | 45 ++++++++++++++++ 6 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 app/views/settings/hostings/_anthropic_settings.html.erb create mode 100644 app/views/settings/hostings/_llm_provider_selector.html.erb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index e6ac154b8..68661eaee 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -166,6 +166,28 @@ class Settings::HostingsController < ApplicationController Setting.openai_json_mode = hosting_params[:openai_json_mode].presence end + if hosting_params.key?(:anthropic_access_token) + token_param = hosting_params[:anthropic_access_token].to_s.strip + unless token_param.blank? || token_param == "********" + Setting.anthropic_access_token = token_param + end + end + + if hosting_params.key?(:anthropic_base_url) + Setting.anthropic_base_url = hosting_params[:anthropic_base_url].presence + end + + if hosting_params.key?(:anthropic_model) + Setting.anthropic_model = hosting_params[:anthropic_model].presence + end + + if hosting_params.key?(:llm_provider) + provider = hosting_params[:llm_provider].to_s + if %w[openai anthropic].include?(provider) + Setting.llm_provider = provider + end + end + LLM_BUDGET_MINIMUMS.each do |key, minimum| next unless hosting_params.key?(key) raw = hosting_params[key].to_s.strip @@ -206,6 +228,8 @@ class Settings::HostingsController < ApplicationController # be wiped because the view reads from the unchanged Setting.* values. @openai_uri_base_input = hosting_params[:openai_uri_base] if hosting_params.key?(:openai_uri_base) @openai_model_input = hosting_params[:openai_model] if hosting_params.key?(:openai_model) + @anthropic_base_url_input = hosting_params[:anthropic_base_url] if hosting_params.key?(:anthropic_base_url) + @anthropic_model_input = hosting_params[:anthropic_model] if hosting_params.key?(:anthropic_model) flash.now[:alert] = error.message render :show, status: :unprocessable_entity end @@ -229,7 +253,7 @@ class Settings::HostingsController < ApplicationController private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) + params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :anthropic_access_token, :anthropic_base_url, :anthropic_model, :llm_provider, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) end def update_assistant_type diff --git a/app/views/settings/hostings/_anthropic_settings.html.erb b/app/views/settings/hostings/_anthropic_settings.html.erb new file mode 100644 index 000000000..34d851d2e --- /dev/null +++ b/app/views/settings/hostings/_anthropic_settings.html.erb @@ -0,0 +1,53 @@ +
+
+

<%= t(".title") %>

+ <% if ENV["ANTHROPIC_ACCESS_TOKEN"].present? || ENV["ANTHROPIC_API_KEY"].present? %> +

<%= t(".env_configured_message") %>

+ <% else %> +

<%= t(".description") %>

+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + class: "space-y-4", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "blur" + } do |form| %> + <%= form.password_field :anthropic_access_token, + label: t(".access_token_label"), + placeholder: t(".access_token_placeholder"), + value: (Setting.anthropic_access_token.present? ? "********" : nil), + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["ANTHROPIC_ACCESS_TOKEN"].present? || ENV["ANTHROPIC_API_KEY"].present?, + data: { "auto-submit-form-target": "auto" } %> + + <%= form.text_field :anthropic_base_url, + label: t(".base_url_label"), + placeholder: t(".base_url_placeholder"), + value: @anthropic_base_url_input || Setting.anthropic_base_url, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "url", + disabled: ENV["ANTHROPIC_BASE_URL"].present?, + data: { "auto-submit-form-target": "auto" } %> + + <%= form.text_field :anthropic_model, + label: t(".model_label"), + placeholder: t(".model_placeholder"), + value: @anthropic_model_input || Setting.anthropic_model, + autocomplete: "off", + autocapitalize: "none", + spellcheck: "false", + inputmode: "text", + disabled: ENV["ANTHROPIC_MODEL"].present?, + data: { "auto-submit-form-target": "auto" } %> +

<%= t(".model_help") %>

+ <% end %> +
diff --git a/app/views/settings/hostings/_llm_provider_selector.html.erb b/app/views/settings/hostings/_llm_provider_selector.html.erb new file mode 100644 index 000000000..3b3406175 --- /dev/null +++ b/app/views/settings/hostings/_llm_provider_selector.html.erb @@ -0,0 +1,38 @@ +
+
+

<%= t(".title") %>

+ <% if ENV["LLM_PROVIDER"].present? %> +

<%= t(".env_configured_message") %>

+ <% else %> +

<%= t(".description") %>

+ <% end %> +
+ + <%= styled_form_with model: Setting.new, + url: settings_hosting_path, + method: :patch, + class: "space-y-3", + data: { + controller: "auto-submit-form", + "auto-submit-form-trigger-event-value": "change" + } do |form| %> + <%= form.select :llm_provider, + options_for_select( + [ + [ t(".provider_openai"), "openai" ], + [ t(".provider_anthropic"), "anthropic" ] + ], + Setting.llm_provider + ), + { label: t(".provider_label") }, + { disabled: ENV["LLM_PROVIDER"].present?, + data: { "auto-submit-form-target": "auto" } } %> +

<%= t(".provider_help") %>

+ <% end %> + +
+

<%= t(".data_retention_heading") %>

+

<%= t(".data_retention_openai") %>

+

<%= t(".data_retention_anthropic") %>

+
+
diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index 6312f900e..16920f5e1 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -4,7 +4,9 @@ <% end %> <%= settings_section title: t(".general") do %>
+ <%= render "settings/hostings/llm_provider_selector" %> <%= render "settings/hostings/openai_settings" %> + <%= render "settings/hostings/anthropic_settings" %> <%= render "settings/hostings/brand_fetch_settings" %>
<% end %> diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index a2f08e4e1..4917a0892 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -90,6 +90,28 @@ en: title: Brand Fetch Settings high_res_label: Enable high-resolution logos high_res_description: When enabled, logos will be retrieved at 120x120 resolution instead of 40x40. This provides sharper images on high-DPI displays. + llm_provider_selector: + title: AI Provider + description: Choose which LLM powers chat, transaction categorization, merchant detection, and PDF processing. + env_configured_message: Successfully configured through the LLM_PROVIDER environment variable. + provider_label: Active LLM provider + provider_openai: OpenAI + provider_anthropic: Anthropic (Claude) + provider_help: Switching providers takes effect on the next chat or batch operation. Make sure the chosen provider's credentials are configured below. + data_retention_heading: Data handling + data_retention_openai: "OpenAI: API inputs are not used to train models by default. Standard retention is 30 days for trust & safety." + data_retention_anthropic: "Anthropic: API inputs are not used to train models by default. Standard retention is 30 days for trust & safety." + anthropic_settings: + title: Anthropic (Claude) + description: Enter your Anthropic API key. Optionally point Base URL at AWS Bedrock or GCP Vertex. + env_configured_message: Successfully configured through environment variables. + access_token_label: API Key + access_token_placeholder: Enter your Anthropic API key + base_url_label: Base URL (Optional) + base_url_placeholder: "https://api.anthropic.com (default)" + model_label: Default Model (Optional) + model_placeholder: "claude-sonnet-4-6 (default)" + model_help: Used for chat and PDF processing. Batch operations (categorize, merchant detection) default to Haiku for cost. openai_settings: description: Enter the access token and optionally configure a custom OpenAI-compatible provider env_configured_message: Successfully configured through environment variables. diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index e4ecca9bb..4c278b897 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -74,6 +74,51 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end end + test "can update anthropic access token when self hosting is enabled" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { anthropic_access_token: "sk-ant-test" } } + + assert_equal "sk-ant-test", Setting.anthropic_access_token + end + end + + test "ignores redacted anthropic token placeholder" do + with_self_hosting do + Setting.anthropic_access_token = "previous-token" + + patch settings_hosting_url, params: { setting: { anthropic_access_token: "********" } } + + assert_equal "previous-token", Setting.anthropic_access_token + end + end + + test "can update anthropic base_url and model" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { anthropic_base_url: "https://bedrock.example.com", anthropic_model: "claude-opus-4-7" } } + + assert_equal "https://bedrock.example.com", Setting.anthropic_base_url + assert_equal "claude-opus-4-7", Setting.anthropic_model + end + end + + test "can update llm_provider to anthropic" do + with_self_hosting do + patch settings_hosting_url, params: { setting: { llm_provider: "anthropic" } } + + assert_equal "anthropic", Setting.llm_provider + end + end + + test "rejects unknown llm_provider values" do + with_self_hosting do + Setting.llm_provider = "openai" + + patch settings_hosting_url, params: { setting: { llm_provider: "bogus" } } + + assert_equal "openai", Setting.llm_provider + end + end + test "can update openai uri base and model together when self hosting is enabled" do with_self_hosting do patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1", openai_model: "gpt-4" } }