Files
sure/app/models/assistant/external.rb
Juan José Mata cade5b22f7 Document admin-only reset auth in OpenAPI docs (#1198)
* Document admin-only reset auth in OpenAPI docs

The DELETE /api/v1/users/reset endpoint now requires admin role
(ensure_admin). Update the rswag spec to:
- Set default user role to admin so the 200 test passes
- Add a 403 response case for non-admin users with read_write scope
- Clarify the description notes admin requirement
- Add SuccessMessage schema and users paths to openapi.yaml

https://claude.ai/code/session_01Tj8ToLRmVg5HLmHwq9KKDY

* Consolidate duplicate 403 responses for reset endpoint

OpenAPI keys responses by status code, so two 403 blocks caused the
first (insufficient scope) to be silently overwritten by the second
(non-admin). Merge into a single 403 whose description covers both
causes: requires read_write scope and admin role. The test exercises
the read-only key path which hits 403 via scope check.

https://claude.ai/code/session_01Tj8ToLRmVg5HLmHwq9KKDY

* Em-dash out of messages.

* Fix tests

* Fix tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-15 00:23:38 +01:00

111 lines
3.4 KiB
Ruby

class Assistant::External < Assistant::Base
Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true)
MAX_CONVERSATION_MESSAGES = 20
class << self
def for_chat(chat)
new(chat)
end
def configured?
config.url.present? && config.token.present?
end
def available_for?(user)
configured? && allowed_user?(user)
end
def allowed_user?(user)
allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"]
return true if allowed.blank?
return false if user&.email.blank?
allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase)
end
def config
Config.new(
url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url.presence,
token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token.presence,
agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main",
session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main")
)
end
end
def respond_to(message)
response_completed = false
unless self.class.configured?
raise Assistant::Error,
"External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables."
end
unless self.class.allowed_user?(chat.user)
raise Assistant::Error, "Your account is not authorized to use the external assistant."
end
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: "external-agent"
)
client = build_client
messages = build_conversation_messages
model = client.chat(
messages: messages,
user: "sure-family-#{chat.user.family_id}"
) do |text|
if assistant_message.content.blank?
stop_thinking
assistant_message.content = text
assistant_message.save!
else
assistant_message.append_text!(text)
end
end
if assistant_message.new_record?
stop_thinking
raise Assistant::Error, "External assistant returned an empty response."
end
response_completed = true
assistant_message.update!(ai_model: model) if model.present?
rescue Assistant::Error, ActiveRecord::ActiveRecordError => e
cleanup_partial_response(assistant_message) unless response_completed
stop_thinking
chat.add_error(e)
rescue => e
Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}")
cleanup_partial_response(assistant_message) unless response_completed
stop_thinking
chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details."))
end
private
def cleanup_partial_response(assistant_message)
assistant_message&.destroy! if assistant_message&.persisted?
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}")
end
def build_client
Assistant::External::Client.new(
url: self.class.config.url,
token: self.class.config.token,
agent_id: self.class.config.agent_id,
session_key: self.class.config.session_key
)
end
def build_conversation_messages
chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg|
{ role: msg.role, content: msg.content }
end
end
end