chore(captain): PoC Codex OAuth device flow + Responses streaming

PoC validado com conta ChatGPT Plus e client_id do Hermes. Device flow
OAuth funciona, gera access_token + refresh_token auto-refresh. Chat e
function calling funcionaram em gpt-5.4, gpt-5.4-mini, gpt-5.2 e
gpt-5.3-codex.

Descobertas pro adapter final:
- Endpoint: /responses (não /chat/completions)
- Streaming obrigatório (stream: true)
- store: false obrigatório
- Sem temperature/top_p (modelos reasoning)
- input[] no lugar de messages[]
- instructions top-level no lugar de system role
- Tools sem wrapping function: {}
- Output via events response.output_item.done (não response.completed)

Pasta scripts/captain_codex_poc/ está excluída do Rubocop (scripts
standalone, não rodam em contexto Rails).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-22 14:56:57 -03:00
parent c512e3e5f6
commit df56ee8115
9 changed files with 586 additions and 0 deletions

View File

@ -240,6 +240,7 @@ AllCops:
- 'reference/**/*' - 'reference/**/*'
- '.aios-core/**/*' - '.aios-core/**/*'
- '.claude/**/*' - '.claude/**/*'
- 'scripts/captain_codex_poc/**/*'
FactoryBot/SyntaxMethods: FactoryBot/SyntaxMethods:
Enabled: false Enabled: false

1
scripts/captain_codex_poc/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tokens.json

View File

@ -0,0 +1,65 @@
# Captain Codex OAuth PoC
Proof-of-concept para validar se a assinatura do ChatGPT Plus pode ser usada no Captain AI via OAuth device flow, reutilizando o `client_id` do Hermes.
## Pré-requisitos
1. **Assinatura ChatGPT Plus ativa** na conta `borbamachadoo@gmail.com`
2. Ruby 3.x instalado (não precisa do bundle do Chatwoot — só stdlib)
## Passos
### 1) Login (device flow)
```bash
ruby scripts/captain_codex_poc/login.rb
```
- Vai imprimir uma URL + um código
- Abra a URL no browser, faça login com `borbamachadoo@gmail.com`, cole o código
- Script detecta a autorização e salva tokens em `scripts/captain_codex_poc/tokens.json`
### 2) Teste de chat simples
```bash
ruby scripts/captain_codex_poc/test_chat.rb
```
Faz uma chamada simples `POST /chat/completions` com `gpt-5.4` e imprime a resposta.
**Critério de sucesso:** resposta HTTP 200 com conteúdo coerente em português.
### 3) Teste de function calling
```bash
ruby scripts/captain_codex_poc/test_tools.rb
```
Faz chamada com uma tool `gerar_pix` simulada e verifica se o modelo:
- Reconhece que precisa chamar a tool
- Retorna `tool_calls` com `function.name` e `function.arguments` corretos
**Critério de sucesso:** `tool_calls` não-nulo e JSON de argumentos válido.
**Critério de go/no-go do projeto Codex OAuth:** se este teste falhar (modelo não suporta function calling via endpoint Codex), **abortamos a implementação**. Os tools do Captain (Pix, reservas, labels) são pré-requisito não-negociável.
### 4) Comparação de qualidade (manual)
```bash
ruby scripts/captain_codex_poc/test_jasmine_like.rb
```
Simula uma conversa estilo Jasmine — cliente pedindo reserva. Compare subjetivamente a resposta com o que a Jasmine faz hoje em produção com `gpt-4o`.
## Arquivos
- `login.rb` — device flow
- `test_chat.rb` — smoke test /chat/completions
- `test_tools.rb` — function calling
- `test_jasmine_like.rb` — qualidade conversacional
- `tokens.json` — access_token + refresh_token (git-ignored)
- `codex_client.rb` — helper compartilhado (refresh + HTTP)
## Segurança
O arquivo `tokens.json` contém credenciais OAuth reais e **NUNCA deve ser commitado**. Já está no `.gitignore` desta pasta.

View File

@ -0,0 +1,193 @@
require 'net/http'
require 'uri'
require 'json'
require 'time'
module CodexPoc
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
ISSUER = 'https://auth.openai.com'.freeze
TOKEN_URL = "#{ISSUER}/oauth/token".freeze
API_BASE = 'https://chatgpt.com/backend-api/codex'.freeze
TOKENS_PATH = File.expand_path('tokens.json', __dir__).freeze
REFRESH_SKEW_SECONDS = 120
class Error < StandardError; end
class Client
def initialize
load_tokens!
end
# Chama a Responses API do Codex em modo streaming (obrigatório nesse endpoint).
# Agrega o texto e tool_calls dos eventos SSE e retorna como objeto consolidado.
def responses(model:, system_prompt: nil, user_messages:, tools: nil, max_tokens: nil)
refresh_if_expiring!
input = user_messages.is_a?(String) ? [{ role: 'user', content: user_messages }] : user_messages
body = {
model: model,
input: input,
store: false,
stream: true
}
body[:instructions] = system_prompt if system_prompt
body[:tools] = tools if tools
body[:tool_choice] = 'auto' if tools
body[:max_output_tokens] = max_tokens if max_tokens
stream_responses("#{API_BASE}/responses", body)
end
# Extrai texto + tool_calls do output consolidado.
def self.extract(resp)
output = resp['output'] || []
text_parts = []
tool_calls = []
output.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 << {
name: item['name'],
arguments: item['arguments'],
call_id: item['call_id'] || item['id']
}
end
end
{ text: text_parts.join("\n"), tool_calls: tool_calls, raw_output: output }
end
private
def load_tokens!
unless File.exist?(TOKENS_PATH)
raise Error, "Tokens não encontrados em #{TOKENS_PATH}. Rode primeiro: ruby scripts/captain_codex_poc/login.rb"
end
data = JSON.parse(File.read(TOKENS_PATH))
@access_token = data.fetch('access_token')
@refresh_token = data.fetch('refresh_token')
@expires_at = data['expires_at'] ? Time.parse(data['expires_at']) : nil
end
def refresh_if_expiring!
return unless @expires_at
return if Time.now < @expires_at - REFRESH_SKEW_SECONDS
puts "[codex_client] Refrescando token (expira em #{@expires_at})..."
resp = post_form(TOKEN_URL, {
grant_type: 'refresh_token',
refresh_token: @refresh_token,
client_id: CLIENT_ID
})
@access_token = resp.fetch('access_token')
@refresh_token = resp['refresh_token'] || @refresh_token
@expires_at = Time.now + Integer(resp['expires_in'] || 3600)
persist_tokens!
end
def persist_tokens!
File.write(TOKENS_PATH, JSON.pretty_generate({
access_token: @access_token,
refresh_token: @refresh_token,
expires_at: @expires_at.iso8601
}))
end
# Consome o stream SSE da Responses API.
# Agrega os items finalizados (output_item.done) + o usage do response.completed.
# Retorna { "output" => [...items...], "usage" => {...} }
def stream_responses(url, body)
uri = URI(url)
items = []
usage = nil
completed = false
final_error = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) do |http|
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)
http.request(req) do |resp|
unless resp.is_a?(Net::HTTPSuccess)
err_body = +''
resp.read_body { |chunk| err_body << chunk }
raise Error, "HTTP #{resp.code} em #{url}: #{err_body[0, 800]}"
end
buffer = +''
resp.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
event = buffer.slice!(0, idx + 2)
parsed = parse_sse_event(event)
next unless parsed
case parsed[:event]
when 'response.output_item.done'
items << parsed[:data]['item'] if parsed[:data]['item']
when 'response.completed'
usage = parsed[:data].dig('response', 'usage')
completed = true
when 'response.failed', 'error'
final_error = parsed[:data]
end
end
end
end
end
raise Error, "Stream falhou: #{final_error.inspect[0, 500]}" if final_error
raise Error, 'Stream terminou sem response.completed' unless completed
{ 'output' => items, 'usage' => usage }
end
# Parseia um bloco SSE ("event: foo\ndata: {...}\n\n")
def parse_sse_event(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
return nil if data_lines.empty?
data_str = data_lines.join("\n")
return nil if data_str == '[DONE]'
parsed = JSON.parse(data_str) rescue nil
return nil unless parsed
{ event: event_name || parsed['type'] || 'message', data: parsed }
end
def post_form(url, params)
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 30
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(params)
resp = http.request(req)
unless resp.is_a?(Net::HTTPSuccess)
raise Error, "HTTP #{resp.code} em #{url}: #{resp.body}"
end
JSON.parse(resp.body)
end
end
end

View File

@ -0,0 +1,114 @@
#!/usr/bin/env ruby
# Device code flow OAuth com OpenAI — reutiliza client_id do Hermes.
# Salva tokens em scripts/captain_codex_poc/tokens.json.
require 'net/http'
require 'uri'
require 'json'
require 'time'
CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'.freeze
ISSUER = 'https://auth.openai.com'.freeze
TOKENS_PATH = File.expand_path('tokens.json', __dir__).freeze
def http_json(method, url, body: nil, headers: {}, form: false)
uri = URI(url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 30
req_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
req = req_class.new(uri)
headers.each { |k, v| req[k] = v }
if body
if form
req['Content-Type'] = 'application/x-www-form-urlencoded'
req.body = URI.encode_www_form(body)
else
req['Content-Type'] = 'application/json'
req.body = JSON.generate(body)
end
end
resp = http.request(req)
[resp.code.to_i, (resp.body.nil? || resp.body.empty? ? {} : (JSON.parse(resp.body) rescue { 'raw' => resp.body }))]
end
puts '=== Captain Codex OAuth — Device Flow ==='
puts
# Passo 1: Pedir device code
puts '[1/4] Solicitando device code...'
status, data = http_json(:post, "#{ISSUER}/api/accounts/deviceauth/usercode", body: { client_id: CLIENT_ID })
abort "Falhou no device code (HTTP #{status}): #{data}" unless status == 200
user_code = data.fetch('user_code')
device_auth_id = data.fetch('device_auth_id')
poll_interval = [3, (data['interval'] || 5).to_i].max
puts
puts '[2/4] Abra o browser na URL abaixo e cole o código:'
puts
puts " URL: \e[36m#{ISSUER}/codex/device\e[0m"
puts " Code: \e[93m#{user_code}\e[0m"
puts
puts "Aguardando autorização (timeout 15min, Ctrl+C para cancelar)..."
# Passo 2: Polling
auth_code = nil
verifier = nil
deadline = Time.now + (15 * 60)
loop do
abort "\nTimeout de 15min atingido." if Time.now > deadline
sleep poll_interval
code, resp = http_json(:post, "#{ISSUER}/api/accounts/deviceauth/token", body: {
device_auth_id: device_auth_id,
user_code: user_code
})
case code
when 200
auth_code = resp.fetch('authorization_code')
verifier = resp.fetch('code_verifier')
break
when 403, 404
print '.'
next
else
abort "\nErro no polling (HTTP #{code}): #{resp}"
end
end
puts "\n[3/4] Autorização recebida, trocando por tokens..."
# Passo 3: Exchange code -> tokens
redirect_uri = "#{ISSUER}/deviceauth/callback"
status, tokens = http_json(:post, "#{ISSUER}/oauth/token", body: {
grant_type: 'authorization_code',
code: auth_code,
redirect_uri: redirect_uri,
client_id: CLIENT_ID,
code_verifier: verifier
}, form: true)
abort "Token exchange falhou (HTTP #{status}): #{tokens}" unless status == 200
access_token = tokens.fetch('access_token')
refresh_token = tokens.fetch('refresh_token')
expires_in = Integer(tokens['expires_in'] || 3600)
expires_at = Time.now + expires_in
File.write(TOKENS_PATH, JSON.pretty_generate({
access_token: access_token,
refresh_token: refresh_token,
expires_at: expires_at.iso8601
}))
puts
puts "[4/4] Sucesso!"
puts " Tokens salvos em: #{TOKENS_PATH}"
puts " Access token expira em: #{expires_at}"
puts
puts "Próximo passo: ruby scripts/captain_codex_poc/test_chat.rb"

View File

@ -0,0 +1,31 @@
#!/usr/bin/env ruby
# Smoke test: /responses com vários modelos via Codex OAuth.
require_relative 'codex_client'
# Testa modelos na ordem do que o Rodrigo quer (gpt-5.4 primeiro).
# Inclui gpt-5.3-codex porque é o que o Hermes usa com sucesso.
MODELS_TO_TRY = %w[gpt-5.4 gpt-5.4-mini gpt-5.2 gpt-5.3-codex].freeze
client = CodexPoc::Client.new
MODELS_TO_TRY.each do |model|
puts "=== Testando modelo: #{model} ==="
begin
resp = client.responses(
model: model,
system_prompt: 'Você é um recepcionista dos Hoteis 1001 Noites. Responda em português do Brasil, de forma breve e direta.',
user_messages: 'Oi, boa tarde. Queria saber se tem diária disponível para esse fim de semana.'
)
out = CodexPoc::Client.extract(resp)
puts "Resposta: #{out[:text]}"
puts "Usage: #{resp['usage']}"
puts
rescue CodexPoc::Error => e
warn "FALHOU para #{model}: #{e.message[0, 300]}"
puts
end
end
puts '=== Fim do teste de chat ==='

View File

@ -0,0 +1,49 @@
#!/usr/bin/env ruby
# Debug: imprime os eventos SSE crus pra entender o formato exato que a Codex API devolve.
require 'net/http'
require 'uri'
require 'json'
require_relative 'codex_client'
tokens = JSON.parse(File.read(CodexPoc::TOKENS_PATH))
access_token = tokens.fetch('access_token')
uri = URI("#{CodexPoc::API_BASE}/responses")
body = {
model: 'gpt-5.4',
input: [{ role: 'user', content: 'Diga em uma frase curta: qual a capital do Brasil?' }],
instructions: 'Seja breve.',
store: false,
stream: true
}
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) do |http|
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)
http.request(req) do |resp|
puts "Status: #{resp.code}"
if resp.code.to_i != 200
err = +''
resp.read_body { |c| err << c }
puts "Erro: #{err}"
exit 1
end
buffer = +''
resp.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
event = buffer.slice!(0, idx + 2)
puts '--- SSE EVENT ---'
puts event
end
end
puts '--- FIM (buffer remanescente) ---'
puts buffer
end
end

View File

@ -0,0 +1,66 @@
#!/usr/bin/env ruby
# Qualidade conversacional: simula a Jasmine via /responses.
require_relative 'codex_client'
JASMINE_SYSTEM = <<~PROMPT.freeze
Você é a Jasmine, recepcionista dos Hoteis 1001 Noites. Atende no WhatsApp.
Regras:
- Sempre em português do Brasil, tom acolhedor mas direto, frases curtas
- Se souber o nome do cliente, SEMPRE começa com "Oi, {primeiro_nome}!"
- Nunca invente preços, diárias ou disponibilidade se não souber, diga que vai verificar
- Identifique o cenário em 1-2 mensagens: pré-reserva, check-in, reclamação, dúvida geral
- Se for pré-reserva, colete: datas, número de hóspedes, tipo de quarto, forma de pagamento
Unidades:
- 1001 Noites Prime (Brasília)
- 1001 Noites Express (Águas Lindas)
- Dolce Amore (Natal)
PROMPT
SCENARIOS = [
{
name: 'Pré-reserva com nome',
contact_name: 'Rodrigo Borba Machado',
user: 'Oi, boa tarde. Queria ver uma diária para esse fim de semana'
},
{
name: 'Cliente anônimo',
contact_name: nil,
user: 'vcs tem quarto pra essa sexta?'
},
{
name: 'Reclamação',
contact_name: 'Maria Silva',
user: 'Acabei de sair do hotel e o ar condicionado não funcionou a noite toda. Paguei R$400 por noite, isso é uma falta de respeito.'
}
].freeze
MODEL = ENV.fetch('CODEX_MODEL', 'gpt-5.4')
client = CodexPoc::Client.new
puts "Modelo: #{MODEL}\n\n"
SCENARIOS.each do |scenario|
puts "=== #{scenario[:name]} ==="
puts "Contato: #{scenario[:contact_name] || '(desconhecido)'}"
puts "Cliente: #{scenario[:user]}"
puts
system_prompt = JASMINE_SYSTEM.dup
system_prompt += "\n\nDados do contato: nome = #{scenario[:contact_name]}." if scenario[:contact_name]
begin
resp = client.responses(
model: MODEL,
system_prompt: system_prompt,
user_messages: scenario[:user]
)
out = CodexPoc::Client.extract(resp)
puts "Jasmine: #{out[:text]}"
rescue CodexPoc::Error => e
warn "FALHOU: #{e.message[0, 300]}"
end
puts "\n---\n\n"
end

View File

@ -0,0 +1,66 @@
#!/usr/bin/env ruby
# Go/no-go crítico: function calling via /responses.
require_relative 'codex_client'
# Formato Responses API: tools sem wrapping `function: {...}`
TOOLS = [
{
type: 'function',
name: 'gerar_pix',
description: 'Gera um Pix para pagamento de reserva de hotel. Use apenas após ter CPF e nome do hóspede.',
strict: false,
parameters: {
type: 'object',
properties: {
cpf: { type: 'string', description: 'CPF do hóspede, apenas dígitos' },
nome: { type: 'string', description: 'Nome completo do hóspede' },
valor: { type: 'number', description: 'Valor em reais' },
descricao: { type: 'string', description: 'Descrição da reserva' }
},
required: %w[cpf nome valor descricao]
}
}
].freeze
SYSTEM = 'Você é um recepcionista de hotel. Quando tiver CPF e nome do hóspede, chame a tool gerar_pix para emitir o pagamento. Nunca invente dados.'.freeze
USER = 'Oi, quero fechar a reserva. Meu nome é Rodrigo Borba Machado, CPF 123.456.789-00. O valor era R$ 320 por uma diária no Prime.'.freeze
MODELS_TO_TRY = %w[gpt-5.4 gpt-5.4-mini gpt-5.2 gpt-5.3-codex].freeze
client = CodexPoc::Client.new
MODELS_TO_TRY.each do |model|
puts "=== Function calling — modelo: #{model} ==="
begin
resp = client.responses(
model: model,
system_prompt: SYSTEM,
user_messages: USER,
tools: TOOLS
)
out = CodexPoc::Client.extract(resp)
if out[:tool_calls].empty?
warn "[FAIL] #{model} NÃO chamou a tool. Texto retornado:"
warn " #{out[:text][0, 400]}"
else
call = out[:tool_calls].first
begin
args = JSON.parse(call[:arguments])
puts "[PASS] #{model} chamou tool '#{call[:name]}' com args:"
puts JSON.pretty_generate(args)
rescue JSON::ParserError => e
warn "[FAIL] #{model} chamou tool mas args não são JSON: #{call[:arguments].inspect} (#{e.message})"
end
end
puts
rescue CodexPoc::Error => e
warn "FALHOU para #{model}: #{e.message[0, 300]}"
puts
end
end
puts '=== Fim do teste de function calling ==='
puts 'GO/NO-GO: se ao menos gpt-5.4 (ou gpt-5.3-codex) passou com args JSON válidos, seguimos.'