HostedGPT is a Ruby on Rails 8.0 app providing a multi-provider conversational AI UI (OpenAI, Anthropic, Groq, Google Gemini, and others via OpenAI-compatible APIs). Users bring their own API keys, create assistants bound to language models, chat with streaming responses, switch models/providers mid-conversation, attach images/files, and use tool/function calling where the provider supports it.
- Outside Docker:
bin/devstarts Puma, SolidQueue worker, and tailwindcss watcher (via Overmind/Procfile.dev). Do not rundb:setupdirectly — it won't configure encryption;bin/devhandlesdb:preparecorrectly. - With Docker:
docker compose up --build. just start/just bash/just overmind/just teardownare Docker convenience wrappers (seejustfile).
- Full suite:
bin/rails testandbin/rails test:system(system tests need a real browser and do not run inside Docker; they run automatically on PRs). - Single file:
bin/rails test test/models/message_test.rb - Single test by line:
bin/rails test test/models/message_test.rb:42 - Docker equivalent:
docker compose run base rails test - Load sample data (users/conversations/etc.) into dev DB:
bin/rails db:fixtures:load
models.ymlandassistants.ymldeclaratively define the language models / assistants provisioned for each user on registration (User::Registerable).- Re-import after editing
models.yml:rails models:import(also runs automatically ondb:prepare/ server restart). - Export current models:
rails models:export[tmp/models.json]. - Similar
rails assistants:import/assistants:export[path]tasks exist forassistants.yml.
- RuboCop is configured (
.rubocop.yml, target Ruby 3.3, Rails cops enabled).
AIBackend::<Provider>classes inapp/services/ai_backend/(open_ai.rb,anthropic.rb,gemini.rb) implement a shared interface for talking to each provider — the base module holdstools.rb(function-calling helpers) andutilities.rb(shared logic).APIService#driver(enum:openai/anthropic/gemini; Groq rides theopenaidriver against a different base URL) selects the backend class viaAPIService#ai_backend.APIService#effective_tokenfalls back to a shared default key (Setting.default_*_key) whenFeature.default_llm_keys?is enabled and the user hasn't set their own token.- Adding a new provider: add an
APIService.driverenum value + base URL constant, implementAIBackend::<NewProvider>mirroring an existing backend's method names (set_client_config,client_method_name,test_execute), add a deterministic test client stub undertest/support/test_client/, and add default models tomodels.yml.
MessagesControllercreates the user's message, then enqueuesGetNextAIMessageJobto fetch the assistant's reply asynchronously (SolidQueue, optionally run in-process viaRUN_SOLID_QUEUE_IN_PUMA=true).- The job calls
ai_backend.new(...).stream_next_conversation_message, accumulating chunks ontomessage.content_textand broadcasting partial updates over ActionCable (Turbo Streams) roughly every 100ms; the message is only persisted once the stream finishes. - Generation can be cancelled mid-stream (
User#last_cancelled_message_id); the job checks this and raisesResponseCancelledto stop cleanly. - Tool/function calls come back as
content_tool_callson the message (not streamed incrementally).GetNextAIMessageJob#call_tools_before_wrapping_upexecutes each tool call viaAIBackend#get_tool_messages_by_calling, persists a message per tool result, and enqueues a follow-upGetNextAIMessageJobso the assistant can respond to the tool output (tools implemented inapp/services/toolbox/, e.g.gmail.rb,google_tasks.rb,image.rb,memory.rb,open_weather.rb). - Provider errors are translated into user-facing chat messages (invalid key, billing/quota, blank response, connection failure) rather than raised to the user; unexpected errors retry up to 3 attempts with backoff before giving up.
- Conversations support editing a message and regenerating replies without losing history. Each
Messagehas anindex(position in the conversation) and aversion; editing/regenerating creates a newversionstarting at that index, andbranched+branched_from_versiontrack where a version forked from another. Seeapp/models/message/version.rbandconversations.ymlfixtures (:versionedexample) for the exact scheme. - Use
conversation.messages.for_conversation_version(version_or_:latest)rather than queryingmessagesdirectly — it resolves the correct set of messages across branches/versions via a real SQL join, not just an association scope.
Personis adelegated_type(personable: User | Tombstone) — the stable identity record;Useris the "real" account data,Tombstonerepresents a deleted-but-referenced person (e.g. left behind so old messages still resolve an author).Client(belongs_to :person) represents one login session/device (platform: ios/android/web/api) and has at most one activeAuthenticationat a time (authenticate_with!/logout!soft-delete the old one).Credentialis an STI-ish per-auth-method row (password_credential,google_credential,gmail_credential,google_tasks_credential,microsoft_graph_credential,http_header_credential,google_app.rb) belonging toUser, each holding encrypted OAuth/external identifiers.- Multiple auth methods (password, Google OAuth, Microsoft Graph OAuth, HTTP header) are gated by feature flags; enabling HTTP header auth automatically disables password and Google auth (see
Feature.features).
- All flags/settings are declared in
config/options.ymland read throughFeature.<name>?/Setting.<name>(both usemethod_missing; an undeclared name raises/aborts loudly rather than silently returning nil — checkoptions.ymlbefore adding a new flag name). Feature.enabled?also checksCurrent.user.preferences[:feature][name]first, so features can be overridden per-user (e.g. dark mode, voice) on top of the global default fromoptions.yml.
- Many models (
APIService,LanguageModel,Assistant,Authentication, etc.) use adeleted_attimestamp +not_deletedscope instead of hard deletes, to preserve historical integrity (e.g. old messages still reference the language model that generated them). Associations typically come in pairs:has_many :things(scopednot_deleted) andhas_many :things_including_deleted.
- Hotwire (Turbo + Stimulus) + Tailwind; no SPA framework. Streaming UI updates arrive via Turbo Stream broadcasts over ActionCable (PostgreSQL adapter), rendered from
app/views/messages/_message.html.erbpartials re-rendered server-side per chunk.
- Active Storage for uploaded/generated images, backed by Postgres by default or Cloudflare R2 when
Feature.cloudflare_storage?is enabled.
- Prefer service objects/POROs in
app/servicesfor provider logic; keep controllers lean. - Prefer early returns over nested conditionals; keep service methods short (~25 lines where practical).
- Don't hard-code model lists in code — use
models.yml+ the import task. - Don't introduce provider-specific conditionals in controllers — encapsulate per-provider behavior in the
AIBackendlayer. - For provider tests, stub the network client (see
test/support/test_client/) rather than recording HTTP cassettes. - When changing streaming logic, add a test asserting partial-chunk emission order as well as final persistence.
- API tokens are stored via
encrypts :token; never log raw tokens (length-only logging is fine). HTTP header auth treats the configured headers as trusted — don't reuse them for untrusted input.