diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb index be8a8a9cc..5255ae69b 100644 --- a/config/initializers/ai_agents.rb +++ b/config/initializers/ai_agents.rb @@ -3,20 +3,14 @@ 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 + settings = Captain::Llm::ProviderConfig.settings + next if settings[:api_key].blank? - if api_key.present? - Agents.configure do |config| - config.openai_api_key = api_key - if api_endpoint.present? - api_base = "#{api_endpoint.chomp('/')}/v1" - config.openai_api_base = api_base - end - config.default_model = model - config.debug = false - end + Agents.configure do |config| + config.openai_api_key = settings[:api_key] + config.openai_api_base = "#{settings[:api_base]}/v1" if settings[:api_base].present? + config.default_model = settings[:model] + config.debug = false end rescue StandardError => e Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}" diff --git a/config/installation_config.yml b/config/installation_config.yml index 34cb736bf..1d02405d2 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -182,6 +182,16 @@ # End of Microsoft Email Channel Config # MARK: Captain Config +- name: CAPTAIN_LLM_PROVIDER + display_title: 'Captain LLM Provider' + description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional) ou openai_codex_oauth (assinatura ChatGPT Plus via proxy interno).' + value: 'openai_api' + locked: false +- name: CAPTAIN_CODEX_PROXY_URL + display_title: 'Captain Codex Proxy URL' + description: 'URL base do proxy Codex interno quando CAPTAIN_LLM_PROVIDER=openai_codex_oauth. Default: http://localhost:3000/codex' + value: 'http://localhost:3000/codex' + locked: false - name: CAPTAIN_OPEN_AI_API_KEY display_title: 'OpenAI API Key' description: 'The API key used to authenticate requests to OpenAI services for Captain AI.' diff --git a/enterprise/app/services/captain/llm/provider_config.rb b/enterprise/app/services/captain/llm/provider_config.rb new file mode 100644 index 000000000..5cf1fdbda --- /dev/null +++ b/enterprise/app/services/captain/llm/provider_config.rb @@ -0,0 +1,78 @@ +# Single source of truth para a configuração do provider LLM do Captain. +# +# Lê CAPTAIN_LLM_PROVIDER e retorna a combinação certa de (api_key, api_base, model): +# +# - openai_api (padrão): usa CAPTAIN_OPEN_AI_API_KEY + CAPTAIN_OPEN_AI_ENDPOINT. +# Mesmo comportamento legado. +# +# - openai_codex_oauth: aponta para o proxy interno +# (CAPTAIN_CODEX_PROXY_URL, default http://localhost:3000/codex) e usa uma +# api_key dummy — o proxy ignora o Authorization header e usa OAuth interno. +# +# O "legacy" ruby-openai usado para PDF/Files API NÃO deve usar esse módulo: +# o endpoint Codex não expõe Files API, então esses serviços continuam +# apontando sempre para OpenAI tradicional. +class Captain::Llm::ProviderConfig + DEFAULT_MODEL = 'gpt-4.1-mini'.freeze + DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com'.freeze + DEFAULT_CODEX_PROXY_URL = 'http://localhost:3000/codex'.freeze + DUMMY_API_KEY = 'codex-oauth'.freeze + + class << self + def provider + cfg('CAPTAIN_LLM_PROVIDER').presence || 'openai_api' + end + + def codex_oauth? + provider == 'openai_codex_oauth' + end + + # Retorna { api_key:, api_base:, model: } para RubyLLM/Agents. + def settings + if codex_oauth? + codex_settings + else + openai_api_settings + end + end + + def api_key + settings[:api_key] + end + + # Base URL "crua", sem /v1. O cliente (ai_agents.rb) adiciona /v1. + def api_base + settings[:api_base] + end + + def model + settings[:model] + end + + private + + def codex_settings + { + api_key: DUMMY_API_KEY, + api_base: (cfg('CAPTAIN_CODEX_PROXY_URL').presence || DEFAULT_CODEX_PROXY_URL).chomp('/'), + model: cfg('CAPTAIN_OPEN_AI_MODEL').presence || default_codex_model + } + end + + def openai_api_settings + { + api_key: cfg('CAPTAIN_OPEN_AI_API_KEY'), + api_base: (cfg('CAPTAIN_OPEN_AI_ENDPOINT').presence || DEFAULT_OPENAI_ENDPOINT).chomp('/'), + model: cfg('CAPTAIN_OPEN_AI_MODEL').presence || DEFAULT_MODEL + } + end + + def default_codex_model + 'gpt-5.4' + end + + def cfg(name) + InstallationConfig.find_by(name: name)&.value + end + end +end diff --git a/lib/llm/config.rb b/lib/llm/config.rb index 48de51022..dc4e1691e 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -1,7 +1,7 @@ require 'ruby_llm' module Llm::Config - DEFAULT_MODEL = 'gpt-4.1-mini'.freeze + DEFAULT_MODEL = Captain::Llm::ProviderConfig::DEFAULT_MODEL class << self def initialized? @@ -31,19 +31,13 @@ module Llm::Config private def configure_ruby_llm + settings = Captain::Llm::ProviderConfig.settings + RubyLLM.configure do |config| - config.openai_api_key = system_api_key if system_api_key.present? - config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present? + config.openai_api_key = settings[:api_key] if settings[:api_key].present? + config.openai_api_base = "#{settings[:api_base]}/v1" if settings[:api_base].present? 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 diff --git a/spec/enterprise/services/captain/llm/provider_config_spec.rb b/spec/enterprise/services/captain/llm/provider_config_spec.rb new file mode 100644 index 000000000..7235a21dc --- /dev/null +++ b/spec/enterprise/services/captain/llm/provider_config_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe Captain::Llm::ProviderConfig do + before do + InstallationConfig.delete_all + end + + describe '.settings' do + context 'when provider is openai_api (default)' do + before do + InstallationConfig.create!(name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'sk-real') + InstallationConfig.create!(name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com') + InstallationConfig.create!(name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini') + end + + it 'returns the traditional OpenAI API settings' do + settings = described_class.settings + expect(settings[:api_key]).to eq('sk-real') + expect(settings[:api_base]).to eq('https://api.openai.com') + expect(settings[:model]).to eq('gpt-4o-mini') + end + + it 'reports codex_oauth? as false' do + expect(described_class.codex_oauth?).to be false + end + end + + context 'when provider is openai_codex_oauth' do + before do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_codex_oauth') + InstallationConfig.create!(name: 'CAPTAIN_CODEX_PROXY_URL', value: 'http://localhost:3000/codex') + end + + it 'returns the proxy settings with a dummy key' do + settings = described_class.settings + expect(settings[:api_key]).to eq(described_class::DUMMY_API_KEY) + expect(settings[:api_base]).to eq('http://localhost:3000/codex') + end + + it 'falls back to default gpt-5.4 model when no custom model is set' do + expect(described_class.settings[:model]).to eq('gpt-5.4') + end + + it 'honors CAPTAIN_OPEN_AI_MODEL override even with Codex OAuth' do + InstallationConfig.create!(name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-5.4-mini') + expect(described_class.settings[:model]).to eq('gpt-5.4-mini') + end + + it 'reports codex_oauth? as true' do + expect(described_class.codex_oauth?).to be true + end + + it 'strips trailing slash from proxy URL' do + InstallationConfig.find_by!(name: 'CAPTAIN_CODEX_PROXY_URL').update!(value: 'http://localhost:3000/codex/') + expect(described_class.settings[:api_base]).to eq('http://localhost:3000/codex') + end + end + + context 'when CAPTAIN_CODEX_PROXY_URL is missing' do + before { InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_codex_oauth') } + + it 'falls back to the default localhost URL' do + expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_CODEX_PROXY_URL) + end + end + end +end