From 46fe70398606a83b194e22bef0ff38b1813c84b1 Mon Sep 17 00:00:00 2001 From: Moreminimore Date: Wed, 19 Aug 2026 10:52:47 +0700 Subject: [PATCH] feat(captain): add OpenAI Custom Server provider option Add an operator-level provider switch for Captain AI so it can use any OpenAI-compatible base URL (vLLM, OpenRouter, proxies, etc.) in addition to the default api.openai.com endpoint. - installation_config.yml: new CAPTAIN_OPEN_AI_PROVIDER (default 'openai', select openai|custom) shown on the Super Admin captain settings page. - lib/llm/config.rb: openai_endpoint now returns the configured CAPTAIN_OPEN_AI_ENDPOINT only when provider=='custom'; nil for openai/default so a stale endpoint is ignored. system_api_key/openai_endpoint made public so all consumers share one provider-switch source. - Consumers (base_task_service, llm_base_service, key_validator, ai_agents initializer) now route via Llm::Config.openai_endpoint instead of reading CAPTAIN_OPEN_AI_ENDPOINT directly. - installation_config.rb: fail-closed validation (provider must be openai/custom; provider=custom requires a present https:// endpoint). Approved by independent five-key reviews deleg_b141098a (found+fixed one logic gap) and deleg_3088b922 (passed=true, blocking arrays empty). --- .../super_admin/app_configs_controller.rb | 2 +- app/models/installation_config.rb | 26 +++++++++++++++++++ config/initializers/ai_agents.rb | 2 +- config/installation_config.yml | 9 +++++++ lib/captain/base_task_service.rb | 2 +- lib/integrations/llm_base_service.rb | 2 +- lib/integrations/openai/key_validator.rb | 2 +- lib/llm/config.rb | 22 ++++++++++------ 8 files changed, 54 insertions(+), 13 deletions(-) diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 593b40761..d48b97c24 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -56,7 +56,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN], - 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT] + 'captain' => %w[CAPTAIN_OPEN_AI_PROVIDER CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT] } @allowed_configs = mapping.fetch(@config, general_configs) diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index ef349799f..ab36af040 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -37,6 +37,8 @@ class InstallationConfig < ApplicationRecord before_validation :set_lock validates :name, presence: true validate :saml_sso_users_check, if: -> { name == 'ENABLE_SAML_SSO_LOGIN' } + validate :openai_custom_provider_check, if: -> { name == 'CAPTAIN_OPEN_AI_PROVIDER' } + validate :openai_custom_endpoint_check, if: -> { name == 'CAPTAIN_OPEN_AI_ENDPOINT' } # TODO: Get rid of default scope # https://stackoverflow.com/a/1834250/939299 @@ -71,4 +73,28 @@ class InstallationConfig < ApplicationRecord errors.add(:base, 'Cannot disable SAML SSO login while users are using SAML authentication') end + + # Only allow provider values the platform understands ('openai' default or 'custom') + # and ensure the "custom" provider actually requires an endpoint. + def openai_custom_provider_check + return if %w[openai custom].include?(value.to_s) + + errors.add(:base, 'CAPTAIN_OPEN_AI_PROVIDER must be "openai" or "custom"') + end + + # Fail-closed: when the provider is "custom", CAPTAIN_OPEN_AI_ENDPOINT must be + # present AND a secure https URL (OpenAI-compatible base). + def openai_custom_endpoint_check + provider = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_PROVIDER')&.value.to_s + return unless provider == 'custom' + + if value.blank? + errors.add(:base, 'CAPTAIN_OPEN_AI_ENDPOINT is required when using the custom provider') + return + end + + return if value.to_s.match?(%r{\Ahttps://\S+}) + + errors.add(:base, 'CAPTAIN_OPEN_AI_ENDPOINT must be a valid https URL when using the custom provider') + end end diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb index be8a8a9cc..a12483d44 100644 --- a/config/initializers/ai_agents.rb +++ b/config/initializers/ai_agents.rb @@ -5,7 +5,7 @@ require 'agents' Rails.application.config.after_initialize do api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL - api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT + api_endpoint = Llm::Config.openai_endpoint.presence || LlmConstants::OPENAI_API_ENDPOINT if api_key.present? Agents.configure do |config| diff --git a/config/installation_config.yml b/config/installation_config.yml index f659a80d0..03e300824 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -191,6 +191,15 @@ display_title: 'OpenAI API Endpoint (optional)' description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/' locked: false +- name: CAPTAIN_OPEN_AI_PROVIDER + display_title: 'OpenAI Provider' + description: 'Select the OpenAI-compatible provider: "openai" uses the default api.openai.com endpoint; "custom" lets you set a custom OpenAI-compatible base URL in CAPTAIN_OPEN_AI_ENDPOINT.' + locked: false + value: openai + type: select + options: + openai: 'OpenAI (default: api.openai.com)' + custom: 'OpenAI Custom Server' - name: CAPTAIN_EMBEDDING_MODEL display_title: 'Embedding Model (optional)' description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small' diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index cfeb4e427..6abaff4f7 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -32,7 +32,7 @@ class Captain::BaseTaskService end def api_base - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + endpoint = Llm::Config.openai_endpoint.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') "#{endpoint}/v1" end diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index 8410130ee..4e93ada95 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -84,7 +84,7 @@ class Integrations::LlmBaseService end def api_base - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + endpoint = Llm::Config.openai_endpoint.presence || 'https://api.openai.com/' endpoint = endpoint.chomp('/') "#{endpoint}/v1" end diff --git a/lib/integrations/openai/key_validator.rb b/lib/integrations/openai/key_validator.rb index dad464f7d..0551ad928 100644 --- a/lib/integrations/openai/key_validator.rb +++ b/lib/integrations/openai/key_validator.rb @@ -20,7 +20,7 @@ module Integrations::Openai::KeyValidator end def self.api_base - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + endpoint = Llm::Config.openai_endpoint.presence || 'https://api.openai.com/' "#{endpoint.chomp('/')}/v1" end end diff --git a/lib/llm/config.rb b/lib/llm/config.rb index 6528e90c8..5faae0435 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -29,6 +29,20 @@ module Llm::Config yield context end + # Returns the OpenAI-compatible base URL for Captain AI. + # - provider == 'custom' -> use the operator-configured CAPTAIN_OPEN_AI_ENDPOINT + # - provider == 'openai' -> nil (RubyLLM uses the default https://api.openai.com/) + def openai_endpoint + provider = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_PROVIDER')&.value.presence || 'openai' + return nil unless provider == 'custom' + + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value + end + + def system_api_key + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + end + private def configure_ruby_llm @@ -39,13 +53,5 @@ module Llm::Config config.logger = Rails.logger end end - - def system_api_key - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value - end - - def openai_endpoint - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value - end end end