diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index a32f9a17e..6d5dddaee 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -8,6 +8,7 @@ class Settings::HostingsController < ApplicationController llm_context_window: 256, llm_max_response_tokens: 64, llm_max_items_per_call: 1, + openai_request_timeout: Provider::Openai::MIN_REQUEST_TIMEOUT, ai_response_timeout: Chat::MIN_RESPONSE_TIMEOUT.to_i }.freeze @@ -270,9 +271,10 @@ class Settings::HostingsController < ApplicationController end private + # Strong parameters for the self-hosting settings form. 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: []) + 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, :openai_request_timeout, :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 diff --git a/app/models/ai_health.rb b/app/models/ai_health.rb index c3e4f86ab..92ccddeac 100644 --- a/app/models/ai_health.rb +++ b/app/models/ai_health.rb @@ -339,11 +339,12 @@ class AiHealth ENV["OPENAI_ACCESS_TOKEN"].presence || Setting.openai_access_token end + # Reports the timeout used by normal LLM requests for the selected provider. def request_timeout(provider) if provider == :anthropic ENV.fetch("ANTHROPIC_REQUEST_TIMEOUT", 600).to_i else - ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i + Provider::Openai.request_timeout end end diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index af44ee3b1..29c095ce9 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -5,6 +5,8 @@ class Provider::Openai < Provider Error = Class.new(Provider::Error) DEFAULT_MODEL = "gpt-4.1".freeze + DEFAULT_REQUEST_TIMEOUT = 60 + MIN_REQUEST_TIMEOUT = 1 SUPPORTED_MODELS = %w[gpt-4 gpt-5 o1 o3].freeze VISION_CAPABLE_MODEL_PREFIXES = %w[gpt-4o gpt-4-turbo gpt-4.1 gpt-5 o1 o3].freeze @@ -18,12 +20,23 @@ class Provider::Openai < Provider ENV["OPENAI_ACCESS_TOKEN"].present? || Setting.openai_access_token.present? end + # Effective per-request HTTP timeout for OpenAI-compatible calls. + # Precedence matches other self-hosting settings: ENV > Setting > default. + def self.request_timeout + configured = ENV["OPENAI_REQUEST_TIMEOUT"].to_s.strip.to_i + configured = Setting.openai_request_timeout.to_i unless configured.positive? + return DEFAULT_REQUEST_TIMEOUT unless configured.positive? + + [ configured, MIN_REQUEST_TIMEOUT ].max + end + + # Builds a client that uses the effective request timeout for every OpenAI call. def initialize(access_token, uri_base: nil, model: nil) client_options = { access_token: access_token } llm_uri_base = uri_base.presence llm_model = model.presence client_options[:uri_base] = llm_uri_base if llm_uri_base.present? - client_options[:request_timeout] = ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i + client_options[:request_timeout] = self.class.request_timeout @client = ::OpenAI::Client.new(**client_options) @uri_base = llm_uri_base diff --git a/app/models/setting.rb b/app/models/setting.rb index deed1d5a6..8db7e878d 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -10,6 +10,7 @@ class Setting < RailsSettings::Base field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] field :openai_json_mode, type: :string, default: ENV["LLM_JSON_MODE"] + field :openai_request_timeout, type: :integer, default: ENV["OPENAI_REQUEST_TIMEOUT"]&.to_i field :anthropic_access_token, type: :string, default: ENV["ANTHROPIC_ACCESS_TOKEN"].presence || ENV["ANTHROPIC_API_KEY"].presence field :anthropic_model, type: :string, default: ENV["ANTHROPIC_MODEL"] field :anthropic_base_url, type: :string, default: ENV["ANTHROPIC_BASE_URL"] diff --git a/app/models/vector_store/openai.rb b/app/models/vector_store/openai.rb index 43d487826..9159ac749 100644 --- a/app/models/vector_store/openai.rb +++ b/app/models/vector_store/openai.rb @@ -7,10 +7,11 @@ # OpenAI manages chunking, embedding, and retrieval; we simply upload files # and issue search queries. class VectorStore::Openai < VectorStore::Base + # Builds a vector-store client with the same OpenAI request timeout as chat and batch calls. def initialize(access_token:, uri_base: nil) client_options = { access_token: access_token } client_options[:uri_base] = uri_base if uri_base.present? - client_options[:request_timeout] = ENV.fetch("OPENAI_REQUEST_TIMEOUT", 60).to_i + client_options[:request_timeout] = Provider::Openai.request_timeout @client = ::OpenAI::Client.new(**client_options) end diff --git a/app/views/settings/hostings/_openai_settings.html.erb b/app/views/settings/hostings/_openai_settings.html.erb index f9dac08ba..12284eac3 100644 --- a/app/views/settings/hostings/_openai_settings.html.erb +++ b/app/views/settings/hostings/_openai_settings.html.erb @@ -103,8 +103,17 @@
<%= t(".ai_response_timeout_description") %>
+<%= t(".timeout_description") %>
+ + <%= form.number_field :openai_request_timeout, + label: t(".openai_request_timeout_label"), + placeholder: Provider::Openai::DEFAULT_REQUEST_TIMEOUT.to_s, + value: ENV["OPENAI_REQUEST_TIMEOUT"].present? ? Provider::Openai.request_timeout : Setting.openai_request_timeout, + min: Provider::Openai::MIN_REQUEST_TIMEOUT, + disabled: ENV["OPENAI_REQUEST_TIMEOUT"].present?, + data: { "auto-submit-form-target": "auto" } %> +<%= t(".openai_request_timeout_help") %>
<%= form.number_field :ai_response_timeout, label: t(".ai_response_timeout_label"), diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 23af61131..fdd79c9de 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -143,10 +143,12 @@ en: max_response_tokens_help: "Tokens reserved for the model's reply. Default: 512. Lower to free up room for longer history." max_items_per_call_label: Max Items Per Batch (Optional) max_items_per_call_help: "Upper bound for auto-categorize / merchant detection batches. Default: 25. Larger batches are auto-sliced to fit the context window." - ai_response_timeout_heading: Chat Response Timeout - ai_response_timeout_description: How long the chat waits for the assistant before showing a "no response" error. Raise this if you run a local model on slow hardware — responses from OpenAI-compatible providers are not streamed, and tool-call rounds display nothing, so the chat can sit on "Thinking…" for the whole turn. + timeout_heading: Timeouts + timeout_description: Controls how long OpenAI-compatible model calls may run before Sure gives up. Raise these if you run a local model on slow hardware. + openai_request_timeout_label: Request Timeout in Seconds (Optional) + openai_request_timeout_help: "Default: 60. Applies to each OpenAI-compatible HTTP request, including auto-categorization, merchant detection, chat, PDF processing, and vector-store calls. OPENAI_REQUEST_TIMEOUT overrides this field." ai_response_timeout_label: Response Timeout in Seconds (Optional) - ai_response_timeout_help: "Default: 90. This covers the whole turn, so size it as a sum: (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT, plus tool execution and queue time. Lowering ASSISTANT_MAX_TOOL_CALL_ITERATIONS reduces how large this needs to be, and is often the better lever on slow hardware." + ai_response_timeout_help: "Default: 90. Applies only to chat's whole-turn watchdog, so size it as a sum: (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × request timeout, plus tool execution and queue time. AI_RESPONSE_TIMEOUT overrides this field." title: OpenAI yahoo_finance_settings: title: Yahoo Finance diff --git a/config/locales/views/settings/hostings/es.yml b/config/locales/views/settings/hostings/es.yml index 6e3004ef3..db449a2fc 100644 --- a/config/locales/views/settings/hostings/es.yml +++ b/config/locales/views/settings/hostings/es.yml @@ -143,10 +143,12 @@ es: max_response_tokens_help: 'Tokens reservados para la respuesta del modelo. Predeterminado: 512. Bájalo para dejar sitio a un historial más largo.' max_items_per_call_label: Elementos máximos por lote (opcional) max_items_per_call_help: 'Límite superior para los lotes de categorización automática / detección de comercios. Predeterminado: 25. Los lotes más grandes se dividen automáticamente para caber en la ventana de contexto.' - ai_response_timeout_heading: Tiempo de espera de respuesta del chat - ai_response_timeout_description: Cuánto tiempo espera el chat al asistente antes de mostrar un error de "sin respuesta". Auméntalo si ejecutas un modelo local en hardware lento; las respuestas de proveedores compatibles con OpenAI no se transmiten por streaming, y las rondas de llamadas a herramientas no muestran nada, así que el chat puede quedarse en "Pensando..." durante todo el turno. + timeout_heading: Tiempos de espera + timeout_description: Controla cuánto tiempo pueden ejecutarse las llamadas a modelos compatibles con OpenAI antes de que Sure las cancele. Auméntalos si ejecutas un modelo local en hardware lento. + openai_request_timeout_label: Tiempo de espera de solicitud en segundos (opcional) + openai_request_timeout_help: "Predeterminado: 60. Se aplica a cada solicitud HTTP compatible con OpenAI, incluida la categorización automática, la detección de comercios, el chat, el procesamiento de PDF y las llamadas al almacén vectorial. OPENAI_REQUEST_TIMEOUT sobrescribe este campo." ai_response_timeout_label: Tiempo de espera de respuesta en segundos (opcional) - ai_response_timeout_help: 'Predeterminado: 90. Cubre todo el turno, así que dimensiona este valor como una suma: (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT, más el tiempo de ejecución de herramientas y cola. Reducir ASSISTANT_MAX_TOOL_CALL_ITERATIONS disminuye cuánto debe crecer este valor y suele ser la mejor palanca en hardware lento.' + ai_response_timeout_help: "Predeterminado: 90. Solo se aplica al monitor de turno completo del chat, así que dimensiona este valor como una suma: (1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × tiempo de espera de solicitud, más el tiempo de ejecución de herramientas y cola. AI_RESPONSE_TIMEOUT sobrescribe este campo." title: OpenAI yahoo_finance_settings: title: Yahoo Finance diff --git a/docs/hosting/ai.md b/docs/hosting/ai.md index f32c44992..15fb6469b 100644 --- a/docs/hosting/ai.md +++ b/docs/hosting/ai.md @@ -220,7 +220,7 @@ OPENAI_MODEL=llama3.1:13b # have enough prompt budget for categories + schemas before transaction rows are added. LLM_CONTEXT_WINDOW=8192 -# Slow local models often need a longer HTTP timeout once the prompt budget issue is fixed. +# Slow local models often need a longer per-request HTTP timeout once the prompt budget issue is fixed. OPENAI_REQUEST_TIMEOUT=180 # Chained tool calls per turn. Each iteration is another call to the model, so @@ -245,7 +245,7 @@ AI_DEBUG_MODE=true - The `OPENAI_ACCESS_TOKEN` can be any non-empty value (Ollama ignores it) - If you don't set a model, chats will fail with a validation error - Auto-categorization uses a conservative default `LLM_CONTEXT_WINDOW=2048`, so large category lists or schemas can exhaust the prompt budget before any transactions are sent -- If requests start timing out after raising `LLM_CONTEXT_WINDOW`, increase `OPENAI_REQUEST_TIMEOUT` too; these are separate limits +- If requests start timing out after raising `LLM_CONTEXT_WINDOW`, increase `OPENAI_REQUEST_TIMEOUT` too; these are separate limits. You can also set this in **Settings → Self-Hosting → OpenAI → Request Timeout** when the environment variable is not configured. - Responses from custom providers are **not streamed** — the chat shows "Thinking…" until the entire reply is generated, and a turn that chains tool calls stays there through every round, since tool-call responses have no text to display. If the chat errors while your model is clearly still working, raise `AI_RESPONSE_TIMEOUT` or lower `ASSISTANT_MAX_TOOL_CALL_ITERATIONS`; `OPENAI_REQUEST_TIMEOUT` alone will not help. `AI_RESPONSE_TIMEOUT` has to cover the whole turn, so size it as `(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) × OPENAI_REQUEST_TIMEOUT` plus tool execution and queue wait — a sum, not simply a larger number than the per-call limit ### Docker Compose Example @@ -1181,7 +1181,7 @@ Keeping the full eight iterations at 300s per call would instead need `9 × 300 If `AI_RESPONSE_TIMEOUT` ends up below what the turn actually takes, you get a generic "no response" instead of the specific timeout error, and the job keeps running and burning tokens after the chat has given up. -`AI_RESPONSE_TIMEOUT` can also be set at **Settings → Self-Hosting → OpenAI → Chat Response Timeout**, which takes effect without a restart. The environment variable wins if both are set. The minimum accepted value is `30`. +`OPENAI_REQUEST_TIMEOUT` and `AI_RESPONSE_TIMEOUT` can also be set at **Settings → Self-Hosting → OpenAI → Timeouts**, which takes effect without a restart when the corresponding environment variable is not configured. Environment variables win over the settings fields. The minimum accepted chat response timeout is `30`. Restart `web` and `worker` after changing the environment variables, and make sure your Docker Compose file forwards them into the containers. diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index cd328164e..e557670fc 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -27,7 +27,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest teardown do # These tests persist global Setting.* values; reset them so state can't # leak into later (order-dependent) tests. - %i[anthropic_access_token anthropic_base_url anthropic_model llm_provider twelve_data_api_key openai_access_token external_assistant_token rentcast_api_key realie_api_key].each do |key| + %i[anthropic_access_token anthropic_base_url anthropic_model llm_provider twelve_data_api_key openai_access_token openai_request_timeout ai_response_timeout external_assistant_token rentcast_api_key realie_api_key].each do |key| Setting.public_send("#{key}=", nil) end end @@ -550,28 +550,38 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest patch settings_hosting_url, params: { setting: { llm_context_window: "4096", llm_max_response_tokens: "1024", - llm_max_items_per_call: "40" + llm_max_items_per_call: "40", + openai_request_timeout: "180", + ai_response_timeout: "240" } } assert_redirected_to settings_hosting_url assert_equal 4096, Setting.llm_context_window assert_equal 1024, Setting.llm_max_response_tokens assert_equal 40, Setting.llm_max_items_per_call + assert_equal 180, Setting.openai_request_timeout + assert_equal 240, Setting.ai_response_timeout patch settings_hosting_url, params: { setting: { llm_context_window: "", llm_max_response_tokens: "", - llm_max_items_per_call: "" + llm_max_items_per_call: "", + openai_request_timeout: "", + ai_response_timeout: "" } } assert_nil Setting.llm_context_window assert_nil Setting.llm_max_response_tokens assert_nil Setting.llm_max_items_per_call + assert_nil Setting.openai_request_timeout + assert_nil Setting.ai_response_timeout end ensure Setting.llm_context_window = nil Setting.llm_max_response_tokens = nil Setting.llm_max_items_per_call = nil + Setting.openai_request_timeout = nil + Setting.ai_response_timeout = nil end test "rejects llm budget below field minimum" do @@ -593,11 +603,33 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_match(/must be a whole number/, flash[:alert]) assert_nil Setting.llm_max_items_per_call + + patch settings_hosting_url, params: { setting: { openai_request_timeout: "0" } } + + assert_response :unprocessable_entity + assert_match(/must be a whole number/, flash[:alert]) + assert_nil Setting.openai_request_timeout end ensure Setting.llm_context_window = nil Setting.llm_max_response_tokens = nil Setting.llm_max_items_per_call = nil + Setting.openai_request_timeout = nil + end + + test "shows environment backed OpenAI request timeout when field is disabled" do + with_self_hosting do + Setting.openai_request_timeout = 180 + + with_env_overrides("OPENAI_REQUEST_TIMEOUT" => "300") do + get settings_hosting_url + + assert_response :success + assert_select "input[name='setting[openai_request_timeout]'][value='300'][disabled='disabled']" + end + end + ensure + Setting.openai_request_timeout = nil end test "can clear data only when admin" do diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb index 652716c6a..10716bc9c 100644 --- a/test/models/provider/openai_test.rb +++ b/test/models/provider/openai_test.rb @@ -8,6 +8,32 @@ class Provider::OpenaiTest < ActiveSupport::TestCase @subject_model = "gpt-4.1" end + test "request_timeout uses ENV then Setting then default" do + Setting.stubs(:openai_request_timeout).returns(nil) + with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil) do + assert_equal Provider::Openai::DEFAULT_REQUEST_TIMEOUT, Provider::Openai.request_timeout + end + + Setting.stubs(:openai_request_timeout).returns(180) + with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil) do + assert_equal 180, Provider::Openai.request_timeout + end + + Setting.stubs(:openai_request_timeout).returns(180) + with_env_overrides("OPENAI_REQUEST_TIMEOUT" => "300") do + assert_equal 300, Provider::Openai.request_timeout + end + end + + test "request_timeout is passed to OpenAI client" do + with_env_overrides("OPENAI_REQUEST_TIMEOUT" => nil) do + Setting.stubs(:openai_request_timeout).returns(180) + ::OpenAI::Client.expects(:new).with(access_token: "test-token", request_timeout: 180).returns(mock) + + Provider::Openai.new("test-token") + end + end + test "openai errors are automatically raised" do VCR.use_cassette("openai/chat/error") do response = @openai.chat_response("Test", model: "invalid-model-that-will-trigger-api-error")