feat(openai): add optional custom base URL to the OpenAI integration
Some checks failed
Frontend Lint & Test / test (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / security-scan (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled

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).
This commit is contained in:
Moreminimore
2026-08-19 11:18:37 +07:00
parent 961ecd388c
commit f9628db0e1
5 changed files with 38 additions and 7 deletions

View File

@@ -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!

View File

@@ -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

View File

@@ -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',

View File

@@ -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

View File

@@ -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