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