Files
sure/app/controllers/settings/hostings_controller.rb
T
Andrew B c9fbfd9f71 fix(chat): make the assistant response timeout configurable (#2910)
* fix(chat): make the assistant response timeout configurable (#2893)

Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.

Three timeouts are involved and only one was configurable:

  - OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
  - The browser watchdog in chat_controller.js (90s) — hardcoded, and
    this is what actually fires.
  - Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
    the client value alone would not have helped.

The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.

Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.

The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.

Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.

* fix(chat): let the watchdog retry when report_timeout declines

`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.

That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.

Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.

* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance

The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.

The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.

Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.

compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.

* fix(chat): claim the pending bubble atomically before appending

append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.

Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.

Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.

* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment

Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.

Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.

Both files now pass with or without AI_RESPONSE_TIMEOUT set.

* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls

The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.

Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.

Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.

* docs(chat): state the whole-turn timeout as a sum, not a maximum

The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is

  (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
    + tool execution + queue wait

Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.

One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.

Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.

* docs(chat): list the AI settings fields and tag the formula fence

The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.

Also tags the whole-turn formula fence as `text` (markdownlint MD040).

* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose

This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.

The tool-call cap was only named in a comment here, while the docs added in
3360dbf5 tell operators to lower it to keep a turn inside AI_RESPONSE_TIMEOUT.
Following that advice on this compose file silently changed nothing: the app kept
the default of 5 while the timeout was sized for 3 calls, which lands back on the
"no response" error this branch exists to fix.

Left with an empty default so the app's own default governs, matching
OPENAI_MODEL and LLM_CONTEXT_WINDOW above. compose.example.ai.yml already
forwarded it.
2026-08-15 06:12:22 +02:00

320 lines
13 KiB
Ruby

class Settings::HostingsController < ApplicationController
layout "settings"
# Minimum accepted value for each configurable numeric LLM field. Mirrors the
# `min:` attribute on the form inputs in `_openai_settings.html.erb` so the
# controller rejects what the browser-side validator would reject.
LLM_NUMERIC_MINIMUMS = {
llm_context_window: 256,
llm_max_response_tokens: 64,
llm_max_items_per_call: 1,
ai_response_timeout: Chat::MIN_RESPONSE_TIMEOUT.to_i
}.freeze
guard_feature unless: -> { self_hosted? }
before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ]
before_action :ensure_super_admin_for_onboarding, only: :update
def show
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("breadcrumbs.self_hosting"), nil ]
]
# Determine which providers are currently selected
exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider
enabled_securities = Setting.enabled_securities_providers
# Show provider settings if used for FX or enabled for securities
@show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data")
@show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance")
@show_tiingo_settings = enabled_securities.include?("tiingo")
@show_eodhd_settings = enabled_securities.include?("eodhd")
@show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage")
# T-Invest doubles as a brand-logo source consulted regardless of the price
# provider, so its token is useful even when it's not enabled for prices.
# Always surface the token field, decoupled from the securities checklist.
@show_tinkoff_invest_settings = true
# Only fetch provider data if we're showing the section
if @show_twelve_data_settings
twelve_data_provider = Provider::Registry.get_provider(:twelve_data)
@twelve_data_usage = twelve_data_provider&.usage
@plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData")
end
if @show_yahoo_finance_settings
@yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance)
@yahoo_finance_health_status = @yahoo_finance_provider&.health_status || :unknown
end
# Property valuation (AVM) providers — usage is shown against their tight
# monthly request caps when a key is configured
@rentcast_usage = Provider::Registry.get_provider(:rentcast)&.usage
@realie_usage = Provider::Registry.get_provider(:realie)&.usage
end
def update
if hosting_params.key?(:onboarding_state)
onboarding_state = hosting_params[:onboarding_state].to_s
Setting.onboarding_state = onboarding_state
end
if hosting_params.key?(:require_email_confirmation)
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
end
if hosting_params.key?(:invite_only_default_family_id)
value = hosting_params[:invite_only_default_family_id].presence
Setting.invite_only_default_family_id = value
end
if hosting_params.key?(:brand_fetch_client_id)
Setting.brand_fetch_client_id = hosting_params[:brand_fetch_client_id]
end
if hosting_params.key?(:brand_fetch_high_res_logos)
Setting.brand_fetch_high_res_logos = hosting_params[:brand_fetch_high_res_logos] == "1"
end
update_encrypted_setting(:twelve_data_api_key)
if hosting_params.key?(:exchange_rate_provider)
Setting.exchange_rate_provider = hosting_params[:exchange_rate_provider]
end
if hosting_params.key?(:securities_provider)
Setting.securities_provider = hosting_params[:securities_provider]
end
if hosting_params.key?(:securities_providers)
new_providers = Array(hosting_params[:securities_providers]).reject(&:blank?) & Security.valid_price_providers
old_providers = Setting.enabled_securities_providers
Setting.securities_providers = new_providers.join(",")
# Clear the legacy singular setting so the fallback in
# enabled_securities_providers doesn't re-enable a provider
# the user just unchecked.
Setting.securities_provider = nil if new_providers.empty?
# Mark securities linked to removed providers as offline so they aren't
# silently queried against an incompatible fallback provider (e.g. MFAPI
# scheme codes sent to TwelveData). The price_provider is preserved so
# provider_status can report :provider_unavailable.
removed = old_providers - new_providers
removed.each do |removed_provider|
Security.where(price_provider: removed_provider, offline: false)
.in_batches.update_all(offline: true, offline_reason: "provider_disabled")
end
# Bring securities back online when their provider is re-enabled — but only
# those that were taken offline by a provider toggle, not by health checks.
added = new_providers - old_providers
added.each do |added_provider|
Security.where(price_provider: added_provider, offline: true, offline_reason: "provider_disabled")
.in_batches.update_all(offline: false, offline_reason: nil, failed_fetch_count: 0, failed_fetch_at: nil)
end
end
update_encrypted_setting(:tiingo_api_key)
update_encrypted_setting(:eodhd_api_key)
update_encrypted_setting(:alpha_vantage_api_key)
update_encrypted_setting(:tinkoff_invest_api_key)
update_encrypted_setting(:rentcast_api_key)
update_encrypted_setting(:realie_api_key)
if hosting_params.key?(:syncs_include_pending)
Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1"
end
sync_settings_changed = false
if hosting_params.key?(:auto_sync_enabled)
Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1"
sync_settings_changed = true
end
if hosting_params.key?(:auto_sync_time)
time_value = hosting_params[:auto_sync_time]
unless Setting.valid_auto_sync_time?(time_value)
flash[:alert] = t(".invalid_sync_time")
return redirect_to settings_hosting_path
end
Setting.auto_sync_time = time_value
Setting.auto_sync_timezone = current_user_timezone
sync_settings_changed = true
end
if sync_settings_changed
sync_auto_sync_scheduler!
end
update_encrypted_setting(:openai_access_token)
# Validate OpenAI configuration before updating
if hosting_params.key?(:openai_uri_base) || hosting_params.key?(:openai_model)
Setting.validate_openai_config!(
uri_base: hosting_params[:openai_uri_base],
model: hosting_params[:openai_model]
)
end
if hosting_params.key?(:openai_uri_base)
Setting.openai_uri_base = hosting_params[:openai_uri_base]
end
if hosting_params.key?(:openai_model)
Setting.openai_model = hosting_params[:openai_model]
end
if hosting_params.key?(:openai_json_mode)
Setting.openai_json_mode = hosting_params[:openai_json_mode].presence
end
update_encrypted_setting(:anthropic_access_token)
if hosting_params.key?(:anthropic_base_url)
raw_base_url = hosting_params[:anthropic_base_url].to_s.strip
if raw_base_url.blank?
Setting.anthropic_base_url = nil
else
parsed = URI.parse(raw_base_url) rescue nil
unless parsed.is_a?(URI::HTTP)
raise Setting::ValidationError, t(".invalid_anthropic_base_url")
end
# A custom Anthropic-compatible endpoint requires a model — Provider::Anthropic
# raises without one. Validate the pair together (mirrors the OpenAI branch), using
# the submitted model when present so a blanked model field is caught too.
effective_model =
if hosting_params.key?(:anthropic_model)
hosting_params[:anthropic_model].to_s.strip
else
Setting.anthropic_model.to_s.strip
end
if effective_model.blank?
raise Setting::ValidationError, t(".anthropic_model_required_for_base_url")
end
Setting.anthropic_base_url = raw_base_url
end
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_NUMERIC_MINIMUMS.each do |key, minimum|
next unless hosting_params.key?(key)
raw = hosting_params[key].to_s.strip
if raw.blank?
Setting.public_send("#{key}=", nil)
next
end
parsed = Integer(raw, 10) rescue nil
if parsed.nil? || parsed < minimum
label = t("settings.hostings.openai_settings.#{key}_label")
raise Setting::ValidationError, t(".invalid_llm_budget", field: label, minimum: minimum)
end
Setting.public_send("#{key}=", parsed)
end
if hosting_params.key?(:external_assistant_url)
Setting.external_assistant_url = hosting_params[:external_assistant_url]
end
update_encrypted_setting(:external_assistant_token)
if hosting_params.key?(:external_assistant_agent_id)
Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id]
end
update_assistant_type
redirect_to settings_hosting_path, notice: t(".success")
rescue Setting::ValidationError => error
# Preserve user-submitted OpenAI config so the form re-renders with their
# input intact (issue #1824). The form auto-submits on blur, so a partial
# entry (e.g. URI base before model) hits validation and would otherwise
# 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
def clear_cache
DataCacheClearJob.perform_later(Current.family)
redirect_to settings_hosting_path, notice: t(".cache_cleared")
end
def disconnect_external_assistant
Setting.external_assistant_url = nil
Setting.external_assistant_token = nil
Setting.external_assistant_agent_id = nil
Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present?
redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected")
rescue => e
Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}")
redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure")
end
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, :tinkoff_invest_api_key, :rentcast_api_key, :realie_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, :ai_response_timeout, :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
return unless params[:family].present? && params[:family][:assistant_type].present?
return if ENV["ASSISTANT_TYPE"].present?
assistant_type = params[:family][:assistant_type]
Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type)
end
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end
def ensure_super_admin_for_onboarding
onboarding_params = %i[onboarding_state invite_only_default_family_id]
return unless onboarding_params.any? { |p| hosting_params.key?(p) }
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.super_admin?
end
def sync_auto_sync_scheduler!
AutoSyncScheduler.sync!
rescue StandardError => error
Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
flash[:alert] = t(".scheduler_sync_failed")
end
def update_encrypted_setting(param_key)
return unless hosting_params.key?(param_key)
value = hosting_params[param_key].to_s.strip
# "********" is the masked placeholder rendered for an existing key; it
# means "leave the stored value untouched". A blank submission, however,
# is an explicit request to clear the key, so persist nil in that case.
return if value == "********"
Setting.public_send(:"#{param_key}=", value.presence)
end
def current_user_timezone
Current.family&.timezone.presence || "UTC"
end
end