mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 13:51:29 +00:00
* 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.
292 lines
9.7 KiB
Ruby
292 lines
9.7 KiB
Ruby
class Chat < ApplicationRecord
|
|
include Debuggable
|
|
|
|
RATE_LIMIT_PATTERNS = [
|
|
/\b429\b/i,
|
|
/rate limit/i,
|
|
/too many requests/i,
|
|
/quota exceeded/i
|
|
].freeze
|
|
|
|
TEMPORARY_PROVIDER_PATTERNS = [
|
|
/\b5\d\d\b/i,
|
|
/service unavailable/i,
|
|
/temporarily unavailable/i,
|
|
/gateway timeout/i,
|
|
/bad gateway/i,
|
|
/overloaded/i,
|
|
/time(?:out|d?\s*out)/i,
|
|
/connection reset/i
|
|
].freeze
|
|
|
|
AUTH_CONFIGURATION_PATTERNS = [
|
|
/unauthorized/i,
|
|
/authentication/i,
|
|
/invalid api key/i,
|
|
/incorrect api key/i,
|
|
/access token/i
|
|
].freeze
|
|
|
|
belongs_to :user
|
|
|
|
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
|
|
has_many :messages, dependent: :destroy
|
|
|
|
validates :title, presence: true
|
|
|
|
scope :ordered, -> { order(created_at: :desc) }
|
|
|
|
class << self
|
|
def start!(prompt, model:)
|
|
# Ensure we have a valid model by using the default if none provided
|
|
effective_model = model.presence || default_model
|
|
|
|
create!(
|
|
title: generate_title(prompt),
|
|
messages: [ UserMessage.new(content: prompt, ai_model: effective_model) ]
|
|
)
|
|
end
|
|
|
|
def generate_title(prompt)
|
|
prompt.first(80)
|
|
end
|
|
|
|
# Returns the default AI model to use for chats.
|
|
# Resolved from the configured llm_provider so installs that swap providers
|
|
# don't have to manually update every chat default. Falls through to a
|
|
# provider that actually has credentials configured, otherwise the chosen
|
|
# provider's classes would later raise "no LLM provider supports model …"
|
|
# even when the other provider is configured.
|
|
def default_model
|
|
prefers_anthropic = Setting.llm_provider == "anthropic"
|
|
|
|
if prefers_anthropic && Provider::Anthropic.configured?
|
|
Provider::Anthropic.effective_model.presence || Setting.anthropic_model
|
|
elsif Provider::Openai.configured?
|
|
Provider::Openai.effective_model.presence || Setting.openai_model
|
|
elsif Provider::Anthropic.configured?
|
|
Provider::Anthropic.effective_model.presence || Setting.anthropic_model
|
|
else
|
|
Provider::Openai.effective_model.presence || Setting.openai_model
|
|
end
|
|
end
|
|
end
|
|
|
|
def needs_assistant_response?
|
|
conversation_messages.ordered.last.role != "assistant"
|
|
end
|
|
|
|
def retry_last_message!
|
|
update!(error: nil)
|
|
|
|
last_message = conversation_messages.ordered.last
|
|
|
|
if last_message.present? && last_message.role == "user"
|
|
|
|
ask_assistant_later(last_message)
|
|
end
|
|
end
|
|
|
|
def update_latest_response!(provider_response_id)
|
|
update!(latest_assistant_response_id: provider_response_id)
|
|
end
|
|
|
|
def add_error(e)
|
|
update!(error: build_error_payload(e).to_json)
|
|
broadcast_append target: messages_target, partial: "chats/error", locals: { chat: self }
|
|
end
|
|
|
|
def presentable_error_message
|
|
return nil if error.blank?
|
|
parsed_error_payload["message"].presence || classify_error_message(error)
|
|
end
|
|
|
|
def technical_error_message
|
|
parsed_error_payload["technical_message"].presence || parsed_legacy_error_message || error
|
|
end
|
|
|
|
def clear_error
|
|
update! error: nil
|
|
broadcast_remove target: error_target
|
|
end
|
|
|
|
def conversation_messages
|
|
messages.where(type: [ "UserMessage", "AssistantMessage" ])
|
|
end
|
|
|
|
def messages_target
|
|
ActionView::RecordIdentifier.dom_id(self, :messages)
|
|
end
|
|
|
|
def error_target
|
|
ActionView::RecordIdentifier.dom_id(self, :chat_error)
|
|
end
|
|
|
|
def ask_assistant_later(message)
|
|
clear_error
|
|
pending = messages.create!(type: "AssistantMessage", content: "", ai_model: message.ai_model, status: :pending)
|
|
AssistantResponseJob.perform_later(message, pending)
|
|
end
|
|
|
|
def ask_assistant(message, assistant_message: nil)
|
|
assistant.respond_to(message, assistant_message: assistant_message)
|
|
end
|
|
|
|
# How long a pending "Thinking…" bubble may wait before the browser watchdog
|
|
# reports it as undelivered. Configurable because a local model on slow
|
|
# hardware can legitimately take minutes to produce its first token, and the
|
|
# custom OpenAI-compatible provider path is non-streaming — so nothing renders
|
|
# until the whole generation finishes.
|
|
DEFAULT_RESPONSE_TIMEOUT = 90.seconds
|
|
|
|
# Floor on the configured value. Below this the watchdog would fire during
|
|
# normal cloud-model latency and kill healthy responses.
|
|
MIN_RESPONSE_TIMEOUT = 30.seconds
|
|
|
|
# How far *below* the client timeout the server's own floor sits, so a client
|
|
# whose clock runs modestly ahead is not refused on its first report. This is
|
|
# only an optimisation to keep retries rare: correctness for arbitrary skew
|
|
# comes from `MessagesController#report_timeout` answering non-OK when it
|
|
# declines, which leaves the watchdog free to try again on its next tick.
|
|
SERVER_TIMEOUT_GRACE = 10.seconds
|
|
|
|
class << self
|
|
# Client-side watchdog timeout. Precedence: ENV > Setting > default, with
|
|
# non-positive values treated as unset (a 0-second timeout is never meant).
|
|
def response_timeout
|
|
configured = ENV["AI_RESPONSE_TIMEOUT"].to_s.strip.to_i
|
|
configured = Setting.ai_response_timeout.to_i unless configured.positive?
|
|
return DEFAULT_RESPONSE_TIMEOUT unless configured.positive?
|
|
|
|
[ configured.seconds, MIN_RESPONSE_TIMEOUT ].max
|
|
end
|
|
|
|
# Same value in milliseconds, for the Stimulus `responseTimeout` value.
|
|
def response_timeout_ms
|
|
response_timeout.to_i * 1000
|
|
end
|
|
|
|
# Minimum age before the server will treat a still-pending response as
|
|
# undelivered. The client clock is untrusted, so the server enforces its own
|
|
# floor — kept just under the client's so a genuine report is never refused.
|
|
def undelivered_response_timeout
|
|
response_timeout - SERVER_TIMEOUT_GRACE
|
|
end
|
|
end
|
|
|
|
# Handles the case where an assistant response was never delivered — the
|
|
# background worker never ran `AssistantResponseJob` (or it died before it
|
|
# could broadcast an error), leaving a `pending` "Thinking…" bubble forever.
|
|
# Mirrors `Assistant::Builtin`'s rescue: clears the dead bubble, records a
|
|
# friendly error + a debug log entry, and broadcasts the error/Retry UI.
|
|
#
|
|
# Driven by an untrusted client watchdog, so the state change is gated behind a
|
|
# row lock + a server-side age check: we re-read the row under lock and only act
|
|
# if it is *still* pending and has genuinely waited past the timeout, so we never
|
|
# race a worker that is finishing a legitimate (slow) response.
|
|
def handle_undelivered_response!(assistant_message)
|
|
return false unless assistant_message.is_a?(AssistantMessage)
|
|
|
|
resolved = assistant_message.with_lock do
|
|
next false unless assistant_message.pending?
|
|
next false if assistant_message.created_at > self.class.undelivered_response_timeout.ago
|
|
|
|
if assistant_message.content.blank?
|
|
assistant_message.destroy!
|
|
else
|
|
# Demote partially-streamed turns to `failed` so history builders exclude them.
|
|
assistant_message.update_columns(status: "failed")
|
|
end
|
|
true
|
|
end
|
|
|
|
return false unless resolved
|
|
|
|
capture_undelivered_response!(assistant_message)
|
|
update!(error: undelivered_error_payload(assistant_message).to_json)
|
|
broadcast_append target: messages_target, partial: "chats/error", locals: { chat: self }
|
|
true
|
|
end
|
|
|
|
private
|
|
|
|
def undelivered_error_payload(assistant_message)
|
|
{
|
|
message: I18n.t("chat.errors.no_response"),
|
|
technical_message: "Assistant response was never delivered. The background worker did not process " \
|
|
"AssistantResponseJob for message ##{assistant_message.id} (model #{assistant_message.ai_model}). " \
|
|
"#{BackgroundJobHealth.summary}",
|
|
type: "DeliveryTimeout"
|
|
}
|
|
end
|
|
|
|
def capture_undelivered_response!(assistant_message)
|
|
DebugLogEntry.capture(
|
|
category: "assistant",
|
|
level: "error",
|
|
message: "Assistant response not delivered — background worker likely down or not polling the high_priority queue",
|
|
source: "chat.delivery_timeout",
|
|
metadata: {
|
|
chat_id: id,
|
|
message_id: assistant_message.id,
|
|
ai_model: assistant_message.ai_model,
|
|
waited_seconds: (Time.current - assistant_message.created_at).round,
|
|
background_jobs: BackgroundJobHealth.snapshot
|
|
},
|
|
family: user&.family
|
|
)
|
|
end
|
|
|
|
def build_error_payload(error)
|
|
technical_message = error_message_for(error)
|
|
|
|
{
|
|
message: classify_error_message(technical_message),
|
|
technical_message: technical_message,
|
|
type: error.class.name
|
|
}
|
|
end
|
|
|
|
def classify_error_message(message)
|
|
normalized_message = message.to_s.strip
|
|
return I18n.t("chat.errors.default") if normalized_message.blank?
|
|
|
|
if RATE_LIMIT_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
|
|
I18n.t("chat.errors.rate_limited")
|
|
elsif TEMPORARY_PROVIDER_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
|
|
I18n.t("chat.errors.temporarily_unavailable")
|
|
elsif AUTH_CONFIGURATION_PATTERNS.any? { |pattern| normalized_message.match?(pattern) }
|
|
I18n.t("chat.errors.misconfigured")
|
|
else
|
|
I18n.t("chat.errors.default")
|
|
end
|
|
end
|
|
|
|
def parsed_error_payload
|
|
return {} if error.blank?
|
|
return error if error.is_a?(Hash)
|
|
|
|
parsed = JSON.parse(error)
|
|
parsed.is_a?(Hash) ? parsed : {}
|
|
rescue JSON::ParserError, TypeError
|
|
{}
|
|
end
|
|
|
|
def error_message_for(error)
|
|
error.respond_to?(:message) ? error.message.to_s : error.to_s
|
|
rescue StandardError
|
|
""
|
|
end
|
|
|
|
def parsed_legacy_error_message
|
|
parsed = JSON.parse(error)
|
|
parsed.is_a?(String) ? parsed : nil
|
|
rescue JSON::ParserError, TypeError
|
|
nil
|
|
end
|
|
|
|
def assistant
|
|
@assistant ||= Assistant.for_chat(self)
|
|
end
|
|
end
|