iachat/enterprise/app/services/captain/llm/provider_config.rb
Rodribm10 b457e84c2f fix(captain): route embeddings to legacy OpenAI + retry transient errors
Resolve duas camadas de problema identificadas em teste end-to-end:

1. Embeddings falhavam com HTTP 404 (/codex/v1/embeddings não existe).
   Solução: Captain::Llm::EmbeddingService sempre usa OpenAI tradicional
   via Llm::Config.with_api_key(legacy_settings). ProviderConfig expõe
   legacy_openai_settings pra isso.

2. Servidor Codex ocasionalmente responde com response.failed +
   code=server_error (instabilidade transitória). Client agora retenta
   até 2x com backoff exponencial (0.5s, 1.5s) em erros retryable:
   HTTP 5xx, server_error no response.failed, ou stream inacabado.

Outras correções nesta etapa:
- Scenario#agent_model: em modo Codex, ignora CAPTAIN_OPEN_AI_MODEL_SCENARIO
  (que pode ter gpt-4o legado) e usa ProviderConfig.model.
- ExtractionService/ContradictionCheckerService/TranslateQueryService:
  trocam constantes hardcoded gpt-4o-mini/gpt-4.1-nano por
  ProviderConfig.light_model (respeitando o provider ativo).
- ProviderConfig.DEFAULT_CODEX_MODEL agora é gpt-5.2 (reconhecido pelo
  RubyLLM; gpt-5.4 não está no catalog do gem).

Validado ponta-a-ponta: WhatsApp → Chatwoot → Jasmine → handoff Daniela
→ faq_lookup com embedding OK → resposta com preços corretos.

Docs em docs/captain-codex-oauth.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:42:31 -03:00

104 lines
3.3 KiB
Ruby

# 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
# Modelo padrão pro Codex. gpt-5.2 é o mais recente reconhecido pelo RubyLLM
# (gpt-5.4 ainda não está no catalog do gem). Ambos são suportados pelo
# endpoint Codex da OpenAI via ChatGPT Plus.
DEFAULT_CODEX_MODEL = 'gpt-5.2'.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.
LIGHT_MODEL_DEFAULTS = {
'openai_api' => 'gpt-4o-mini',
'openai_codex_oauth' => DEFAULT_CODEX_MODEL
}.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
# Modelo pra tasks leves (memory extraction, contradiction check, etc).
# Respeita a flag de provider: em Codex OAuth, usa o mesmo modelo do chat.
def light_model
LIGHT_MODEL_DEFAULTS[provider] || LIGHT_MODEL_DEFAULTS['openai_api']
end
# Settings sempre da OpenAI tradicional, independente do provider.
# Usado por recursos que o endpoint Codex NÃO expõe: /embeddings e Files API.
# Lança AuthError se não houver CAPTAIN_OPEN_AI_API_KEY configurada.
def legacy_openai_settings
{
api_key: cfg('CAPTAIN_OPEN_AI_API_KEY'),
api_base: (cfg('CAPTAIN_OPEN_AI_ENDPOINT').presence || DEFAULT_OPENAI_ENDPOINT).chomp('/')
}
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 cfg(name)
InstallationConfig.find_by(name: name)&.value
end
end
end