feat(captain): Codex OAuth auth module + proxy controller

Implementa Fases 1+2 do plano Captain Codex OAuth.

Fase 1 — Auth módulo:
- Migration captain_codex_credentials (tokens AR-encrypted)
- Model Captain::CodexCredential (singleton-ish com .current)
- Captain::Codex::AuthService com device flow completo:
  start_device_login, poll_once, exchange_for_credential,
  valid_access_token (auto-refresh), refresh!
- Rake task captain:codex:{login,status,refresh}
- Sidekiq job Captain::Codex::RefreshTokensJob rodando a cada 30min

Fase 2 — Proxy Chat Completions → Responses:
- Captain::Codex::Translator (chat ↔ responses, tools, tool_calls)
- Captain::Codex::Client (streaming SSE → agregado)
- Api::Internal::CodexProxyController expondo
  POST /codex/v1/chat/completions
- 10 specs do Translator passando

Próximo: Fase 3 (feature flag + fallback) e reconfiguração dos
clientes RubyLLM/Agents/ruby-openai pra apontarem pro proxy quando
CAPTAIN_LLM_PROVIDER=openai_codex_oauth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-22 15:07:01 -03:00
parent df56ee8115
commit 928b1ec6b9
13 changed files with 916 additions and 2 deletions

View File

@ -89,6 +89,7 @@ Metrics/BlockLength:
- spec/**/*
- '**/routes.rb'
- 'config/environments/*'
- 'lib/tasks/**/*.rake'
- db/schema.rb
Metrics/ModuleLength:

View File

@ -0,0 +1,57 @@
# Proxy interno que traduz OpenAI Chat Completions ↔ OpenAI Responses (Codex).
#
# Recebe requests no formato Chat Completions (o que RubyLLM, Agents gem e
# ruby-openai geram) e encaminha para a Responses API do ChatGPT Plus (Codex)
# usando OAuth interno via Captain::Codex::AuthService.
#
# Rota: POST /codex/v1/chat/completions
#
# Acesso: interno (não autenticado — localhost-only via Docker network).
# Em produção, o Nginx NÃO expõe /codex/* publicamente.
class Api::Internal::CodexProxyController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def chat_completions
chat_body = request.request_parameters.presence || parse_body
return render_error('Empty request body', status: 400) if chat_body.blank?
render json: proxy_call(chat_body)
rescue Captain::Codex::AuthService::AuthError, Captain::Codex::Client::Error, StandardError => e
handle_proxy_error(e)
end
private
def handle_proxy_error(error)
case error
when Captain::Codex::AuthService::AuthError
Rails.logger.error("[Codex Proxy] Auth error: #{error.message}")
render_error("Codex auth error: #{error.message}", status: 401)
when Captain::Codex::Client::Error
Rails.logger.error("[Codex Proxy] Upstream error: #{error.message}")
render_error("Upstream error: #{error.message}", status: error.http_status || 502)
else
Rails.logger.error("[Codex Proxy] Unexpected: #{error.class} #{error.message}\n#{error.backtrace.first(5).join("\n")}")
render_error("Internal error: #{error.message}", status: 500)
end
end
def proxy_call(chat_body)
responses_body = Captain::Codex::Translator.chat_to_responses(chat_body)
aggregated = Captain::Codex::Client.new.responses(responses_body)
Captain::Codex::Translator.responses_to_chat(aggregated)
end
def parse_body
raw = request.raw_post
return {} if raw.blank?
JSON.parse(raw)
rescue JSON::ParserError
{}
end
def render_error(message, status:)
render json: { error: { message: message, type: 'codex_proxy_error' } }, status: status
end
end

View File

@ -749,4 +749,13 @@ Rails.application.routes.draw do
# ----------------------------------------------------------------------
# Routes for testing
resources :widget_tests, only: [:index] unless Rails.env.production?
# ----------------------------------------------------------------------
# Captain Codex proxy: traduz Chat Completions (cliente) ↔ Responses (Codex).
# Uso interno apenas — o Nginx não deve expor /codex/* publicamente.
namespace :codex do
namespace :v1 do
post 'chat/completions', to: '/api/internal/codex_proxy#chat_completions'
end
end
end

View File

@ -13,6 +13,14 @@ trigger_scheduled_items_job:
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
# Refresh proativo dos tokens OAuth do ChatGPT Plus (Captain Codex).
# Roda a cada 30min e renova access_tokens que estão a <5min de expirar.
# Garante que não tenha cold start causado por token vencido em horário de pico.
captain_codex_refresh_tokens_job:
cron: '*/30 * * * *'
class: 'Captain::Codex::RefreshTokensJob'
queue: scheduled_jobs
# Fallback pra draws da roleta revelados cujo callback do front falhou.
# Executa a cada 5 minutos.
captain_roleta_notify_revealed_scheduler_job:

View File

@ -0,0 +1,18 @@
class CreateCaptainCodexCredentials < ActiveRecord::Migration[7.1]
def change
create_table :captain_codex_credentials do |t|
t.text :access_token, null: false
t.text :refresh_token, null: false
t.datetime :expires_at, null: false
t.datetime :last_refresh_at
t.string :chatgpt_account_id
t.string :chatgpt_plan_type
t.string :email
t.string :status, null: false, default: 'active'
t.timestamps
end
add_index :captain_codex_credentials, :status
add_index :captain_codex_credentials, :expires_at
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_22_105901) do
ActiveRecord::Schema[7.1].define(version: 2026_04_22_145733) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -353,6 +353,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_22_105901) do
t.index ["account_id"], name: "index_captain_brands_on_account_id"
end
create_table "captain_codex_credentials", force: :cascade do |t|
t.text "access_token", null: false
t.text "refresh_token", null: false
t.datetime "expires_at", null: false
t.datetime "last_refresh_at"
t.string "chatgpt_account_id"
t.string "chatgpt_plan_type"
t.string "email"
t.string "status", default: "active", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["expires_at"], name: "index_captain_codex_credentials_on_expires_at"
t.index ["status"], name: "index_captain_codex_credentials_on_status"
end
create_table "captain_configurations", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "title", default: "Reserva Rápida"
@ -1158,7 +1173,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_22_105901) do
t.string "evolution_api_token_iv"
t.jsonb "provider_connection", default: {}
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
t.index ["provider_connection"], name: "index_channel_whatsapp_provider_connection", where: "((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[]))", using: :gin
t.index ["provider_connection"], name: "index_channel_whatsapp_provider_connection", where: "((provider)::text = ANY (ARRAY[('baileys'::character varying)::text, ('zapi'::character varying)::text]))", using: :gin
end
create_table "companies", force: :cascade do |t|

View File

@ -0,0 +1,12 @@
class Captain::Codex::RefreshTokensJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Captain::CodexCredential.active.expiring_soon(5.minutes).find_each do |cred|
Captain::Codex::AuthService.refresh!(cred)
Rails.logger.info("[Captain::Codex] Refreshed credential #{cred.id} (expires #{cred.expires_at})")
rescue Captain::Codex::AuthService::AuthError => e
Rails.logger.error("[Captain::Codex] Refresh failed for credential #{cred.id}: #{e.message}")
end
end
end

View File

@ -0,0 +1,32 @@
class Captain::CodexCredential < ApplicationRecord
self.table_name = 'captain_codex_credentials'
STATUSES = %w[active expired revoked].freeze
encrypts :access_token
encrypts :refresh_token
validates :access_token, presence: true
validates :refresh_token, presence: true
validates :expires_at, presence: true
validates :status, inclusion: { in: STATUSES }
scope :active, -> { where(status: 'active') }
scope :expiring_soon, ->(skew = 5.minutes) { where(expires_at: ..(Time.current + skew)) }
def self.current
active.order(updated_at: :desc).first
end
def needs_refresh?(skew_seconds: 120)
expires_at.nil? || expires_at <= Time.current + skew_seconds.seconds
end
def expired?
expires_at <= Time.current
end
def revoke!
update!(status: 'revoked')
end
end

View File

@ -0,0 +1,171 @@
require 'faraday'
# Gerencia credenciais OAuth do OpenAI Codex (ChatGPT Plus).
#
# Fluxo device code:
# 1. start_device_login → retorna code + URL pro usuário
# 2. usuário autoriza no browser
# 3. poll_for_authorization → espera o usuário aprovar
# 4. exchange_for_credential → salva tokens no DB
#
# Durante uso normal do Captain:
# - valid_access_token → retorna access_token, refrescando se próximo de expirar
class Captain::Codex::AuthService
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
ISSUER = 'https://auth.openai.com'.freeze
USERCODE_URL = "#{ISSUER}/api/accounts/deviceauth/usercode".freeze
DEVICE_TOKEN_URL = "#{ISSUER}/api/accounts/deviceauth/token".freeze
OAUTH_TOKEN_URL = "#{ISSUER}/oauth/token".freeze
REDIRECT_URI = "#{ISSUER}/deviceauth/callback".freeze
VERIFY_URL = "#{ISSUER}/codex/device".freeze
REFRESH_SKEW_SECONDS = 120
class AuthError < StandardError; end
class PendingAuthorization < StandardError; end
class << self
# Passo 1: solicita um device code.
def start_device_login
response = http.post(USERCODE_URL) do |req|
req.headers['Content-Type'] = 'application/json'
req.body = { client_id: CLIENT_ID }.to_json
end
raise AuthError, "Failed to request device code: #{response.status} #{response.body}" unless response.success?
data = JSON.parse(response.body)
{
user_code: data.fetch('user_code'),
device_auth_id: data.fetch('device_auth_id'),
poll_interval: [3, data['interval'].to_i].max,
verify_url: VERIFY_URL
}
end
# Passo 3: polling. Retorna { authorization_code, code_verifier } ou raises.
def poll_once(device_auth_id:, user_code:)
response = http.post(DEVICE_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/json'
req.body = { device_auth_id: device_auth_id, user_code: user_code }.to_json
end
case response.status
when 200
data = JSON.parse(response.body)
{ authorization_code: data.fetch('authorization_code'), code_verifier: data.fetch('code_verifier') }
when 403, 404
raise PendingAuthorization
else
raise AuthError, "Polling failed: #{response.status} #{response.body}"
end
end
# Passo 4: troca o authorization_code por tokens e persiste.
def exchange_for_credential(authorization_code:, code_verifier:)
response = http.post(OAUTH_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(
grant_type: 'authorization_code',
code: authorization_code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: code_verifier
)
end
raise AuthError, "Token exchange failed: #{response.status} #{response.body}" unless response.success?
tokens = JSON.parse(response.body)
save_credential!(tokens)
end
# Retorna access_token válido, fazendo refresh se necessário.
# Lança AuthError se não houver credencial ativa.
def valid_access_token
cred = Captain::CodexCredential.current
raise AuthError, 'No active Codex credential. Run: rails captain:codex:login' if cred.nil?
cred = refresh!(cred) if cred.needs_refresh?(skew_seconds: REFRESH_SKEW_SECONDS)
cred.access_token
end
def refresh!(credential)
response = post_refresh_request(credential.refresh_token)
unless response.success?
credential.update!(status: 'expired')
raise AuthError, "Refresh failed: #{response.status} #{response.body}. Re-run: rails captain:codex:login"
end
tokens = JSON.parse(response.body)
credential.update!(refresh_attributes(tokens, credential))
credential
end
def post_refresh_request(refresh_token)
http.post(OAUTH_TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(
grant_type: 'refresh_token',
refresh_token: refresh_token,
client_id: CLIENT_ID
)
end
end
def refresh_attributes(tokens, credential)
{
access_token: tokens.fetch('access_token'),
refresh_token: tokens['refresh_token'].presence || credential.refresh_token,
expires_at: Time.current + tokens['expires_in'].to_i.seconds,
last_refresh_at: Time.current,
status: 'active'
}
end
private
def save_credential!(tokens)
id_claims = decode_id_token(tokens['id_token'])
attrs = credential_attributes(tokens, id_claims)
cred = Captain::CodexCredential.current || Captain::CodexCredential.new
cred.assign_attributes(attrs)
cred.save!
cred
end
def credential_attributes(tokens, id_claims)
openai_auth = id_claims['https://api.openai.com/auth'] || {}
{
access_token: tokens.fetch('access_token'),
refresh_token: tokens.fetch('refresh_token'),
expires_at: Time.current + tokens['expires_in'].to_i.seconds,
last_refresh_at: Time.current,
chatgpt_account_id: openai_auth['chatgpt_account_id'],
chatgpt_plan_type: openai_auth['chatgpt_plan_type'],
email: id_claims['email'],
status: 'active'
}
end
def decode_id_token(jwt)
return {} if jwt.blank?
payload_b64 = jwt.split('.')[1]
return {} if payload_b64.blank?
JSON.parse(Base64.urlsafe_decode64(payload_b64 + ('=' * (-payload_b64.length % 4))))
rescue StandardError
{}
end
def http
Faraday.new do |f|
f.options.timeout = 30
f.options.open_timeout = 15
end
end
end
end

View File

@ -0,0 +1,125 @@
require 'net/http'
# Faz chamadas à Responses API do OpenAI Codex (via ChatGPT Plus OAuth).
#
# Uso:
# body = Captain::Codex::Translator.chat_to_responses(chat_body)
# aggregated = Captain::Codex::Client.new.responses(body)
# chat_resp = Captain::Codex::Translator.responses_to_chat(aggregated)
#
# O cliente sempre faz streaming (exigência do endpoint Codex) e agrega
# os eventos SSE em um response final no mesmo formato do /responses síncrono.
class Captain::Codex::Client
API_BASE = 'https://chatgpt.com/backend-api/codex'.freeze
class Error < StandardError
attr_reader :http_status
def initialize(message, http_status: nil)
super(message)
@http_status = http_status
end
end
def responses(body)
access_token = Captain::Codex::AuthService.valid_access_token
state = { items: [], usage: nil, id: nil, model: nil, completed: false, error: nil }
stream_post(access_token, body) { |event, data| handle_event(event, data, state) }
raise Error, "Stream failed: #{state[:error].inspect[0, 500]}" if state[:error]
raise Error, 'Stream finished without response.completed' unless state[:completed]
{ 'id' => state[:id], 'model' => state[:model], 'output' => state[:items], 'usage' => state[:usage] }
end
private
def handle_event(event, data, state)
case event
when 'response.created'
state[:id] = data.dig('response', 'id')
state[:model] = data.dig('response', 'model')
when 'response.output_item.done'
state[:items] << data['item'] if data['item']
when 'response.completed'
state[:usage] = data.dig('response', 'usage')
state[:model] ||= data.dig('response', 'model')
state[:completed] = true
when 'response.failed', 'error'
state[:error] = data
end
end
# Abre streaming pro Codex. Chama o bloco com (event_name, data_hash).
def stream_post(access_token, body, &)
uri = URI("#{API_BASE}/responses")
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
req = build_request(uri, access_token, body)
http.request(req) { |resp| consume_stream(resp, &) }
end
end
def build_request(uri, access_token, body)
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/json'
req['Authorization'] = "Bearer #{access_token}"
req['Accept'] = 'text/event-stream'
req.body = JSON.generate(body)
req
end
def consume_stream(resp)
unless resp.is_a?(Net::HTTPSuccess)
err_body = +''
resp.read_body { |chunk| err_body << chunk }
raise Error.new("HTTP #{resp.code}: #{err_body[0, 800]}", http_status: resp.code.to_i)
end
buffer = +''
resp.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
raw_event = buffer.slice!(0, idx + 2)
parsed = parse_sse_event(raw_event)
yield(parsed[:event], parsed[:data]) if parsed
end
end
end
def parse_sse_event(raw)
event_name, data_lines = parse_sse_lines(raw)
return nil if data_lines.empty?
data_str = data_lines.join("\n")
return nil if data_str == '[DONE]'
parsed = safe_json_parse(data_str)
return nil unless parsed
{ event: event_name || parsed['type'] || 'message', data: parsed }
end
def parse_sse_lines(raw)
event_name = nil
data_lines = []
raw.each_line do |line|
line = line.chomp
next if line.empty?
if line.start_with?('event:')
event_name = line.sub('event:', '').strip
elsif line.start_with?('data:')
data_lines << line.sub('data:', '').strip
end
end
[event_name, data_lines]
end
def safe_json_parse(str)
JSON.parse(str)
rescue JSON::ParserError
nil
end
end

View File

@ -0,0 +1,181 @@
# Traduz payloads entre os formatos:
# OpenAI Chat Completions (legado, o que o Captain usa)
# OpenAI Responses API (o que o endpoint ChatGPT Plus Codex exige)
#
# Opera em cima de hashes — sem I/O. I/O fica no Client.
class Captain::Codex::Translator
# --- Request: chat completions → responses ---
# chat_body: hash no formato OpenAI Chat Completions.
# Retorna hash pro POST /responses.
def self.chat_to_responses(chat_body)
messages = chat_body['messages'] || chat_body[:messages] || []
instructions, input = split_system(messages)
body = {
model: chat_body['model'] || chat_body[:model],
input: input,
store: false,
stream: true # Codex exige streaming sempre — o Client agrega.
}
body[:instructions] = instructions if instructions
body[:max_output_tokens] = chat_body['max_tokens'] if chat_body['max_tokens']
tools = chat_body['tools'] || chat_body[:tools]
if tools.present?
body[:tools] = tools.map { |t| translate_tool(t) }
body[:tool_choice] = translate_tool_choice(chat_body['tool_choice'] || chat_body[:tool_choice])
end
body
end
# Separa a(s) mensagem(ns) system do resto.
# Várias system messages viram uma única instruction com \n\n.
def self.split_system(messages)
systems = []
input = []
messages.each do |raw|
msg = raw.stringify_keys
translate_message(msg, systems: systems, input: input)
end
[systems.any? ? systems.join("\n\n") : nil, input]
end
def self.translate_message(msg, systems:, input:)
case msg['role']
when 'system'
systems << stringify_content(msg['content'])
when 'tool'
input << translate_tool_result(msg)
when 'assistant'
translate_assistant_message(msg, input)
else
input << { role: msg['role'], content: stringify_content(msg['content']) }
end
end
def self.translate_tool_result(msg)
{
type: 'function_call_output',
call_id: msg['tool_call_id'],
output: stringify_content(msg['content'])
}
end
def self.translate_assistant_message(msg, input)
tool_calls = msg['tool_calls']
if tool_calls.present?
tool_calls.each { |tc| input << translate_historical_tool_call(tc) }
input << { role: 'assistant', content: stringify_content(msg['content']) } if msg['content'].present?
else
input << { role: 'assistant', content: stringify_content(msg['content']) }
end
end
def self.translate_historical_tool_call(tool_call)
fn = tool_call['function'] || {}
{
type: 'function_call',
call_id: tool_call['id'],
name: fn['name'],
arguments: fn['arguments']
}
end
# Chat: { type: "function", function: { name, description, parameters } }
# Responses: { type: "function", name, description, parameters, strict }
def self.translate_tool(tool)
tool = tool.stringify_keys
fn = (tool['function'] || {}).stringify_keys
{
type: 'function',
name: fn['name'],
description: fn['description'],
parameters: fn['parameters'] || { type: 'object', properties: {} },
strict: fn['strict'] || false
}.compact
end
def self.translate_tool_choice(choice)
return 'auto' if choice.nil?
return choice if choice.is_a?(String) # auto, none, required
if choice.is_a?(Hash)
choice = choice.stringify_keys
return { type: 'function', name: choice.dig('function', 'name') } if choice['type'] == 'function'
end
'auto'
end
def self.stringify_content(content)
return '' if content.nil?
return content if content.is_a?(String)
return content.map { |part| part.is_a?(Hash) ? (part['text'] || part[:text]) : part.to_s }.join("\n") if content.is_a?(Array)
content.to_s
end
# --- Response: responses (agregado) → chat completions ---
# aggregated: { "output" => [items...], "usage" => {...}, "id" => "resp_...", "model" => "..." }
# Retorna hash formato Chat Completions.
def self.responses_to_chat(aggregated)
text_parts, tool_calls = extract_output(aggregated['output'] || [])
message = build_assistant_message(text_parts, tool_calls)
{
id: aggregated['id'] || "chatcmpl-#{SecureRandom.hex(12)}",
object: 'chat.completion',
created: Time.current.to_i,
model: aggregated['model'],
choices: [{ index: 0, message: message, finish_reason: tool_calls.any? ? 'tool_calls' : 'stop' }],
usage: translate_usage(aggregated['usage'])
}
end
def self.extract_output(items)
text_parts = []
tool_calls = []
items.each do |item|
case item['type']
when 'message'
Array(item['content']).each do |part|
text_parts << part['text'] if part['type'] == 'output_text' && part['text']
end
when 'function_call'
tool_calls << {
id: item['call_id'] || item['id'],
type: 'function',
function: { name: item['name'], arguments: item['arguments'] || '{}' }
}
end
end
[text_parts, tool_calls]
end
def self.build_assistant_message(text_parts, tool_calls)
message = { role: 'assistant' }
message[:content] = text_parts.any? ? text_parts.join("\n") : nil
message[:tool_calls] = tool_calls if tool_calls.any?
message
end
# Codex usage: { input_tokens, output_tokens, total_tokens }
# Chat usage: { prompt_tokens, completion_tokens, total_tokens }
def self.translate_usage(usage)
return nil if usage.nil?
usage = usage.stringify_keys
{
prompt_tokens: usage['input_tokens'] || 0,
completion_tokens: usage['output_tokens'] || 0,
total_tokens: usage['total_tokens'] || 0
}
end
end

View File

@ -0,0 +1,81 @@
namespace :captain do
namespace :codex do
desc 'Autentica o Captain AI com a assinatura ChatGPT Plus via device flow OAuth'
task login: :environment do
start = Captain::Codex::AuthService.start_device_login
puts
puts '=== Captain Codex OAuth Login ==='
puts
puts '1. Abra a URL abaixo no browser:'
puts " \e[36m#{start[:verify_url]}\e[0m"
puts
puts '2. Faça login com a conta ChatGPT Plus e cole o código:'
puts " \e[93m#{start[:user_code]}\e[0m"
puts
puts 'Aguardando autorização (timeout 15min, Ctrl+C para cancelar)...'
deadline = 15.minutes.from_now
result = nil
loop do
raise 'Timeout de 15min atingido' if Time.current > deadline
sleep start[:poll_interval]
begin
result = Captain::Codex::AuthService.poll_once(
device_auth_id: start[:device_auth_id],
user_code: start[:user_code]
)
break
rescue Captain::Codex::AuthService::PendingAuthorization
print '.'
end
end
puts
puts 'Autorização recebida, trocando por tokens...'
cred = Captain::Codex::AuthService.exchange_for_credential(
authorization_code: result[:authorization_code],
code_verifier: result[:code_verifier]
)
puts
puts '✓ Login OK'
puts " Email: #{cred.email}"
puts " Plan: #{cred.chatgpt_plan_type}"
puts " ChatGPT account: #{cred.chatgpt_account_id}"
puts " Access token expira: #{cred.expires_at}"
puts ' Refresh automático antes de cada call.'
end
desc 'Mostra status da credencial Codex ativa'
task status: :environment do
cred = Captain::CodexCredential.current
if cred.nil?
puts 'Nenhuma credencial Codex ativa. Rode: rails captain:codex:login'
exit 1
end
puts "Status: #{cred.status}"
puts "Email: #{cred.email}"
puts "Plan: #{cred.chatgpt_plan_type}"
puts "Expires at: #{cred.expires_at}"
puts "Last refresh: #{cred.last_refresh_at}"
puts "Needs refresh? #{cred.needs_refresh?}"
end
desc 'Força refresh da credencial Codex atual'
task refresh: :environment do
cred = Captain::CodexCredential.current
if cred.nil?
puts 'Nenhuma credencial ativa.'
exit 1
end
refreshed = Captain::Codex::AuthService.refresh!(cred)
puts "Refreshed. Nova expiração: #{refreshed.expires_at}"
end
end
end

View File

@ -0,0 +1,204 @@
require 'rails_helper'
RSpec.describe Captain::Codex::Translator do
describe '.chat_to_responses' do
it 'converts a simple chat completion to responses format' do
chat = {
'model' => 'gpt-5.4',
'messages' => [
{ 'role' => 'system', 'content' => 'Você é um assistente.' },
{ 'role' => 'user', 'content' => 'Oi' }
]
}
result = described_class.chat_to_responses(chat)
expect(result[:model]).to eq('gpt-5.4')
expect(result[:stream]).to be true
expect(result[:store]).to be false
expect(result[:instructions]).to eq('Você é um assistente.')
expect(result[:input]).to eq([{ role: 'user', content: 'Oi' }])
end
it 'joins multiple system messages into instructions' do
chat = {
'model' => 'gpt-5.4',
'messages' => [
{ 'role' => 'system', 'content' => 'Parte 1' },
{ 'role' => 'system', 'content' => 'Parte 2' },
{ 'role' => 'user', 'content' => 'Oi' }
]
}
result = described_class.chat_to_responses(chat)
expect(result[:instructions]).to eq("Parte 1\n\nParte 2")
end
it 'converts tools from chat format to responses format' do
chat = {
'model' => 'gpt-5.4',
'messages' => [{ 'role' => 'user', 'content' => 'Oi' }],
'tools' => [
{
'type' => 'function',
'function' => {
'name' => 'gerar_pix',
'description' => 'Gera Pix',
'parameters' => { 'type' => 'object', 'properties' => { 'valor' => { 'type' => 'number' } }, 'required' => ['valor'] }
}
}
]
}
result = described_class.chat_to_responses(chat)
expect(result[:tools]).to eq([
{
type: 'function',
name: 'gerar_pix',
description: 'Gera Pix',
parameters: { 'type' => 'object', 'properties' => { 'valor' => { 'type' => 'number' } },
'required' => ['valor'] },
strict: false
}
])
expect(result[:tool_choice]).to eq('auto')
end
it 'translates tool_choice for specific function' do
chat = {
'model' => 'gpt-5.4',
'messages' => [{ 'role' => 'user', 'content' => 'Oi' }],
'tools' => [{ 'type' => 'function', 'function' => { 'name' => 'x' } }],
'tool_choice' => { 'type' => 'function', 'function' => { 'name' => 'x' } }
}
result = described_class.chat_to_responses(chat)
expect(result[:tool_choice]).to eq({ type: 'function', name: 'x' })
end
it 'translates assistant tool_calls history into function_call items' do
chat = {
'model' => 'gpt-5.4',
'messages' => [
{ 'role' => 'user', 'content' => 'Gera Pix' },
{
'role' => 'assistant',
'content' => nil,
'tool_calls' => [
{ 'id' => 'call_abc', 'type' => 'function',
'function' => { 'name' => 'gerar_pix', 'arguments' => '{"valor":100}' } }
]
},
{ 'role' => 'tool', 'tool_call_id' => 'call_abc', 'content' => '{"ok":true}' },
{ 'role' => 'assistant', 'content' => 'Pronto!' }
]
}
result = described_class.chat_to_responses(chat)
expect(result[:input]).to eq([
{ role: 'user', content: 'Gera Pix' },
{ type: 'function_call', call_id: 'call_abc', name: 'gerar_pix', arguments: '{"valor":100}' },
{ type: 'function_call_output', call_id: 'call_abc', output: '{"ok":true}' },
{ role: 'assistant', content: 'Pronto!' }
])
end
it 'maps max_tokens to max_output_tokens' do
chat = {
'model' => 'gpt-5.4',
'messages' => [{ 'role' => 'user', 'content' => 'Oi' }],
'max_tokens' => 500
}
expect(described_class.chat_to_responses(chat)[:max_output_tokens]).to eq(500)
end
it 'drops unsupported parameters like temperature' do
chat = {
'model' => 'gpt-5.4',
'messages' => [{ 'role' => 'user', 'content' => 'Oi' }],
'temperature' => 0.7,
'top_p' => 0.9,
'frequency_penalty' => 0.1
}
result = described_class.chat_to_responses(chat)
expect(result.keys).not_to include(:temperature, :top_p, :frequency_penalty)
end
end
describe '.responses_to_chat' do
it 'converts a text-only response to chat completion format' do
aggregated = {
'id' => 'resp_abc',
'model' => 'gpt-5.4',
'output' => [
{
'type' => 'message',
'content' => [{ 'type' => 'output_text', 'text' => 'Brasília.' }]
}
],
'usage' => { 'input_tokens' => 10, 'output_tokens' => 3, 'total_tokens' => 13 }
}
result = described_class.responses_to_chat(aggregated)
expect(result[:id]).to eq('resp_abc')
expect(result[:object]).to eq('chat.completion')
expect(result[:model]).to eq('gpt-5.4')
expect(result[:choices].first[:message]).to eq(role: 'assistant', content: 'Brasília.')
expect(result[:choices].first[:finish_reason]).to eq('stop')
expect(result[:usage]).to eq(prompt_tokens: 10, completion_tokens: 3, total_tokens: 13)
end
it 'converts a function_call response to tool_calls format' do
aggregated = {
'id' => 'resp_xyz',
'model' => 'gpt-5.4',
'output' => [
{
'type' => 'function_call',
'call_id' => 'call_abc',
'name' => 'gerar_pix',
'arguments' => '{"valor":320}'
}
],
'usage' => { 'input_tokens' => 50, 'output_tokens' => 10, 'total_tokens' => 60 }
}
result = described_class.responses_to_chat(aggregated)
message = result[:choices].first[:message]
expect(message[:role]).to eq('assistant')
expect(message[:content]).to be_nil
expect(message[:tool_calls]).to eq([
{ id: 'call_abc', type: 'function', function: { name: 'gerar_pix', arguments: '{"valor":320}' } }
])
expect(result[:choices].first[:finish_reason]).to eq('tool_calls')
end
it 'handles both text and tool_calls in the same response' do
aggregated = {
'id' => 'resp_mix',
'model' => 'gpt-5.4',
'output' => [
{ 'type' => 'message', 'content' => [{ 'type' => 'output_text', 'text' => 'Gerando...' }] },
{ 'type' => 'function_call', 'call_id' => 'c1', 'name' => 'x', 'arguments' => '{}' }
],
'usage' => nil
}
result = described_class.responses_to_chat(aggregated)
message = result[:choices].first[:message]
expect(message[:content]).to eq('Gerando...')
expect(message[:tool_calls].size).to eq(1)
expect(result[:choices].first[:finish_reason]).to eq('tool_calls')
end
end
end