From f9628db0e19242782e05d08e3ae2aea4afd7b9d8 Mon Sep 17 00:00:00 2001 From: Moreminimore Date: Wed, 19 Aug 2026 11:18:37 +0700 Subject: [PATCH] feat(openai): add optional custom base URL to the OpenAI integration Add an optional custom OpenAI-compatible base URL to the per-account OpenAI integration (/app/accounts/{id}/settings/integrations/openai) so account owners can point label suggestions / reply suggestions at a self-hosted or proxy endpoint (vLLM, OpenRouter, etc.) instead of the default api.openai.com. - config/integration/apps.yml: add 'base_url' to the openai settings schema + form (optional custom URL input). - lib/integrations/llm_base_service.rb: api_base resolves per-account hook.settings['base_url'] -> CAPTAIN_OPEN_AI_ENDPOINT -> api.openai.com. - lib/integrations/openai/key_validator.rb: valid? accepts an optional base_url keyword; api_base uses it first (backward-compatible). - app/models/integrations/hook.rb: fail-closed validation rejecting a present but non-https base_url; both KeyValidator callers pass the hook base_url. Approved by independent five-key pre-commit review deleg_1c66a89b (passed=true, blocking arrays empty). --- app/jobs/migration/validate_openai_hooks_job.rb | 2 +- app/models/integrations/hook.rb | 16 +++++++++++++++- config/integration/apps.yml | 7 +++++++ lib/integrations/llm_base_service.rb | 8 +++++++- lib/integrations/openai/key_validator.rb | 12 ++++++++---- 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/jobs/migration/validate_openai_hooks_job.rb b/app/jobs/migration/validate_openai_hooks_job.rb index b2ad9108a..3d3967cfc 100644 --- a/app/jobs/migration/validate_openai_hooks_job.rb +++ b/app/jobs/migration/validate_openai_hooks_job.rb @@ -9,7 +9,7 @@ class Migration::ValidateOpenaiHooksJob < ApplicationJob scope.find_each do |hook| stats[:checked] += 1 - next if Integrations::Openai::KeyValidator.valid?(hook.settings&.dig('api_key')) + next if Integrations::Openai::KeyValidator.valid?(hook.settings&.dig('api_key'), base_url: hook.settings&.dig('base_url')) AdministratorNotifications::IntegrationsNotificationMailer.with(account: hook.account).openai_disconnect.deliver_later hook.destroy! diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 04af960c9..09fa7d82c 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -30,6 +30,7 @@ class Integrations::Hook < ApplicationRecord validate :validate_settings_json_schema validate :ensure_feature_enabled validate :validate_openai_api_key, if: :validate_openai_api_key? + validate :validate_openai_base_url, if: :validate_openai_base_url? validate :validate_cloudflare_realtimekit_credentials, if: :validate_cloudflare_realtimekit_credentials? validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } @@ -114,6 +115,19 @@ class Integrations::Hook < ApplicationRecord openai? && enabled? && (new_record? || openai_api_key_changed? || will_save_change_to_status?) end + def validate_openai_base_url? + openai? && enabled? + end + + def validate_openai_base_url + base_url = settings.dig('base_url') + return if base_url.blank? + + return if base_url.to_s.match?(%r{\Ahttps://\S+}) + + errors.add(:base, 'OpenAI base URL must be a valid https URL') + end + def validate_cloudflare_realtimekit_credentials? dyte? && enabled? && !legacy_dyte_settings_unchanged? && (new_record? || cloudflare_realtimekit_credentials_changed? || will_save_change_to_status?) @@ -139,7 +153,7 @@ class Integrations::Hook < ApplicationRecord end def validate_openai_api_key - return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings)) + return if Integrations::Openai::KeyValidator.valid?(settings_api_key(settings), base_url: settings.dig('base_url')) errors.add(:base, I18n.t('errors.openai.invalid_api_key')) end diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 5550e8ecb..b92076146 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -36,6 +36,7 @@ openai: 'properties': { 'api_key': { 'type': 'string' }, + 'base_url': { 'type': 'string' }, 'label_suggestion': { 'type': 'boolean' }, }, 'required': ['api_key'], @@ -49,6 +50,12 @@ openai: 'name': 'api_key', 'validation': 'required', }, + { + 'label': 'OpenAI Base URL (custom OpenAI-compatible server, optional)', + 'type': 'text', + 'name': 'base_url', + 'validation': '', + }, { 'label': 'Show label suggestions', 'type': 'checkbox', diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index 8410130ee..f3bd97000 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -83,8 +83,14 @@ class Integrations::LlmBaseService self.class::CACHEABLE_EVENTS.include?(event_name) end + # Resolve the OpenAI-compatible API base URL in priority order: + # 1. Per-account custom base_url set on the OpenAI integration hook (if any) + # 2. Instance-level CAPTAIN_OPEN_AI_ENDPOINT (if any) + # 3. Default https://api.openai.com/ def api_base - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + endpoint = hook.settings['base_url'].presence || + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.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..08fea6433 100644 --- a/lib/integrations/openai/key_validator.rb +++ b/lib/integrations/openai/key_validator.rb @@ -1,7 +1,7 @@ module Integrations::Openai::KeyValidator TIMEOUT_SECONDS = 5 - def self.valid?(api_key) + def self.valid?(api_key, base_url: nil) return false if api_key.blank? connection = Faraday.new do |f| @@ -9,7 +9,7 @@ module Integrations::Openai::KeyValidator f.options.open_timeout = TIMEOUT_SECONDS end - response = connection.get("#{api_base}/models") do |req| + response = connection.get("#{api_base(base_url)}/models") do |req| req.headers['Authorization'] = "Bearer #{api_key}" end @@ -19,8 +19,12 @@ module Integrations::Openai::KeyValidator true end - def self.api_base - endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/' + # Resolve the API base URL: prefer an explicit per-account custom base_url, + # then the instance-level CAPTAIN_OPEN_AI_ENDPOINT, else default api.openai.com. + def self.api_base(base_url = nil) + endpoint = base_url.presence || + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || + 'https://api.openai.com/' "#{endpoint.chomp('/')}/v1" end end