diff --git a/config/installation_config.yml b/config/installation_config.yml index 1d02405d2..e4892b526 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -184,7 +184,7 @@ # 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).' + description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional), openai_codex_oauth (assinatura ChatGPT Plus via proxy interno) ou openai_hermes_gateway (Hermes Agent rodando como gateway HTTP local — ele faz o roteamento multi-modelo via OAuth).' value: 'openai_api' locked: false - name: CAPTAIN_CODEX_PROXY_URL @@ -192,6 +192,21 @@ 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_HERMES_GATEWAY_URL + display_title: 'Captain Hermes Gateway URL' + description: 'URL base do Hermes Gateway quando CAPTAIN_LLM_PROVIDER=openai_hermes_gateway. Default: http://host.docker.internal:9877 (Hermes rodando no host, container alcança via host.docker.internal).' + value: 'http://host.docker.internal:9877' + locked: false +- name: CAPTAIN_HERMES_GATEWAY_MODEL + display_title: 'Captain Hermes Gateway Model' + description: 'Modelo a passar pro Hermes Gateway no formato /. Default: anthropic/claude-opus-4-5. O Hermes faz o roteamento real e pode usar Codex/Anthropic/Gemini conforme config local em ~/.hermes/config.yaml.' + value: 'anthropic/claude-opus-4-5' + locked: false +- name: CAPTAIN_HERMES_GATEWAY_API_KEY + display_title: 'Captain Hermes Gateway API Key (optional)' + description: 'API key opcional pro Hermes Gateway. Geralmente vazio (gateway local não exige auth). Se setado, vai no Authorization header das requisições do Captain pro Hermes.' + locked: false + type: secret - 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 index ba84be7c3..f3004187a 100644 --- a/enterprise/app/services/captain/llm/provider_config.rb +++ b/enterprise/app/services/captain/llm/provider_config.rb @@ -9,9 +9,16 @@ # (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. # +# - openai_hermes_gateway: aponta para o Hermes Agent rodando em modo gateway +# (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877). +# O Hermes Gateway expõe API HTTP compatível com OpenAI e roteia internamente +# pra Codex/Anthropic/Gemini conforme sua config local em ~/.hermes/config.yaml. +# Auth: usa CAPTAIN_HERMES_GATEWAY_API_KEY se setado, senão dummy (gateway local). +# # 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. +# o endpoint Codex/Hermes não expõe Files API nem /embeddings, então esses +# serviços continuam apontando sempre para OpenAI tradicional via +# legacy_openai_settings. class Captain::Llm::ProviderConfig DEFAULT_MODEL = 'gpt-4.1-mini'.freeze DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com'.freeze @@ -23,12 +30,20 @@ class Captain::Llm::ProviderConfig # endpoint Codex da OpenAI via ChatGPT Plus. DEFAULT_CODEX_MODEL = 'gpt-5.2'.freeze + # Hermes Gateway: defaults para o setup standard do Hermes Agent rodando + # como gateway HTTP local. O gateway escuta em 0.0.0.0:9877 por padrão e + # aceita o nome do modelo no formato `/`. + DEFAULT_HERMES_GATEWAY_URL = 'http://host.docker.internal:9877'.freeze + DEFAULT_HERMES_GATEWAY_MODEL = 'anthropic/claude-opus-4-5'.freeze + HERMES_GATEWAY_DUMMY_KEY = 'hermes-gateway'.freeze + # Modelo leve pra tasks de background (extração de memória, verificação de - # contradição, traduções internas). Quando usamos Codex, reutilizamos o - # mesmo modelo do chat — o endpoint não expõe gpt-4o-mini. + # contradição, traduções internas). Quando usamos Codex/Hermes, reutilizamos + # o mesmo modelo do chat — esses endpoints não expõem gpt-4o-mini. LIGHT_MODEL_DEFAULTS = { 'openai_api' => 'gpt-4o-mini', - 'openai_codex_oauth' => DEFAULT_CODEX_MODEL + 'openai_codex_oauth' => DEFAULT_CODEX_MODEL, + 'openai_hermes_gateway' => DEFAULT_HERMES_GATEWAY_MODEL }.freeze class << self @@ -40,12 +55,16 @@ class Captain::Llm::ProviderConfig provider == 'openai_codex_oauth' end + def hermes_gateway? + provider == 'openai_hermes_gateway' + end + # Retorna { api_key:, api_base:, model: } para RubyLLM/Agents. def settings - if codex_oauth? - codex_settings - else - openai_api_settings + case provider + when 'openai_codex_oauth' then codex_settings + when 'openai_hermes_gateway' then hermes_gateway_settings + else openai_api_settings end end @@ -88,6 +107,14 @@ class Captain::Llm::ProviderConfig } end + def hermes_gateway_settings + { + api_key: cfg('CAPTAIN_HERMES_GATEWAY_API_KEY').presence || HERMES_GATEWAY_DUMMY_KEY, + api_base: (cfg('CAPTAIN_HERMES_GATEWAY_URL').presence || DEFAULT_HERMES_GATEWAY_URL).chomp('/'), + model: cfg('CAPTAIN_HERMES_GATEWAY_MODEL').presence || DEFAULT_HERMES_GATEWAY_MODEL + } + end + def openai_api_settings { api_key: cfg('CAPTAIN_OPEN_AI_API_KEY'), diff --git a/spec/enterprise/services/captain/llm/provider_config_spec.rb b/spec/enterprise/services/captain/llm/provider_config_spec.rb index 5e9ee518e..45dbd463e 100644 --- a/spec/enterprise/services/captain/llm/provider_config_spec.rb +++ b/spec/enterprise/services/captain/llm/provider_config_spec.rb @@ -63,5 +63,72 @@ RSpec.describe Captain::Llm::ProviderConfig do expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_CODEX_PROXY_URL) end end + + context 'when provider is openai_hermes_gateway' do + before do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway') + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_URL', value: 'http://host.docker.internal:9877') + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL', value: 'anthropic/claude-opus-4-5') + end + + it 'returns the gateway URL with dummy api_key when no key is configured' do + settings = described_class.settings + expect(settings[:api_key]).to eq(described_class::HERMES_GATEWAY_DUMMY_KEY) + expect(settings[:api_base]).to eq('http://host.docker.internal:9877') + expect(settings[:model]).to eq('anthropic/claude-opus-4-5') + end + + it 'honors CAPTAIN_HERMES_GATEWAY_API_KEY when present' do + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_API_KEY', value: 'sk-hermes-real') + expect(described_class.settings[:api_key]).to eq('sk-hermes-real') + end + + it 'honors a custom CAPTAIN_HERMES_GATEWAY_MODEL value' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').update!(value: 'openai/gpt-5.4') + expect(described_class.settings[:model]).to eq('openai/gpt-5.4') + end + + it 'reports hermes_gateway? as true and codex_oauth? as false' do + expect(described_class.hermes_gateway?).to be true + expect(described_class.codex_oauth?).to be false + end + + it 'strips trailing slash from gateway URL' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').update!(value: 'http://host.docker.internal:9877/') + expect(described_class.settings[:api_base]).to eq('http://host.docker.internal:9877') + end + + it 'uses default model when CAPTAIN_HERMES_GATEWAY_MODEL is missing' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').delete + expect(described_class.settings[:model]).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL) + end + + it 'uses default URL when CAPTAIN_HERMES_GATEWAY_URL is missing' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').delete + expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_HERMES_GATEWAY_URL) + end + end + + context 'when default provider (openai_api) is in use' do + it 'reports hermes_gateway? as false' do + expect(described_class.hermes_gateway?).to be false + end + end + + describe '.light_model' do + it 'returns gpt-4o-mini for openai_api' do + expect(described_class.light_model).to eq('gpt-4o-mini') + end + + it 'returns DEFAULT_CODEX_MODEL for openai_codex_oauth' do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_codex_oauth') + expect(described_class.light_model).to eq(described_class::DEFAULT_CODEX_MODEL) + end + + it 'returns DEFAULT_HERMES_GATEWAY_MODEL for openai_hermes_gateway' do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway') + expect(described_class.light_model).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL) + end + end end end