From df56ee81153da750dfd2b5eeb863233a970188bd Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 22 Apr 2026 14:56:57 -0300 Subject: [PATCH] chore(captain): PoC Codex OAuth device flow + Responses streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .rubocop.yml | 1 + scripts/captain_codex_poc/.gitignore | 1 + scripts/captain_codex_poc/README.md | 65 ++++++ scripts/captain_codex_poc/codex_client.rb | 193 ++++++++++++++++++ scripts/captain_codex_poc/login.rb | 114 +++++++++++ scripts/captain_codex_poc/test_chat.rb | 31 +++ scripts/captain_codex_poc/test_debug.rb | 49 +++++ .../captain_codex_poc/test_jasmine_like.rb | 66 ++++++ scripts/captain_codex_poc/test_tools.rb | 66 ++++++ 9 files changed, 586 insertions(+) create mode 100644 scripts/captain_codex_poc/.gitignore create mode 100644 scripts/captain_codex_poc/README.md create mode 100755 scripts/captain_codex_poc/codex_client.rb create mode 100755 scripts/captain_codex_poc/login.rb create mode 100755 scripts/captain_codex_poc/test_chat.rb create mode 100755 scripts/captain_codex_poc/test_debug.rb create mode 100755 scripts/captain_codex_poc/test_jasmine_like.rb create mode 100755 scripts/captain_codex_poc/test_tools.rb diff --git a/.rubocop.yml b/.rubocop.yml index 1e374594b..e4b5320a7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -240,6 +240,7 @@ AllCops: - 'reference/**/*' - '.aios-core/**/*' - '.claude/**/*' + - 'scripts/captain_codex_poc/**/*' FactoryBot/SyntaxMethods: Enabled: false diff --git a/scripts/captain_codex_poc/.gitignore b/scripts/captain_codex_poc/.gitignore new file mode 100644 index 000000000..bd5e5e177 --- /dev/null +++ b/scripts/captain_codex_poc/.gitignore @@ -0,0 +1 @@ +tokens.json diff --git a/scripts/captain_codex_poc/README.md b/scripts/captain_codex_poc/README.md new file mode 100644 index 000000000..97b9d5f2f --- /dev/null +++ b/scripts/captain_codex_poc/README.md @@ -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. diff --git a/scripts/captain_codex_poc/codex_client.rb b/scripts/captain_codex_poc/codex_client.rb new file mode 100755 index 000000000..5aa6c8f24 --- /dev/null +++ b/scripts/captain_codex_poc/codex_client.rb @@ -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 diff --git a/scripts/captain_codex_poc/login.rb b/scripts/captain_codex_poc/login.rb new file mode 100755 index 000000000..11154693f --- /dev/null +++ b/scripts/captain_codex_poc/login.rb @@ -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" diff --git a/scripts/captain_codex_poc/test_chat.rb b/scripts/captain_codex_poc/test_chat.rb new file mode 100755 index 000000000..f4e88f467 --- /dev/null +++ b/scripts/captain_codex_poc/test_chat.rb @@ -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 ===' diff --git a/scripts/captain_codex_poc/test_debug.rb b/scripts/captain_codex_poc/test_debug.rb new file mode 100755 index 000000000..cb0397f58 --- /dev/null +++ b/scripts/captain_codex_poc/test_debug.rb @@ -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 diff --git a/scripts/captain_codex_poc/test_jasmine_like.rb b/scripts/captain_codex_poc/test_jasmine_like.rb new file mode 100755 index 000000000..89d384aa7 --- /dev/null +++ b/scripts/captain_codex_poc/test_jasmine_like.rb @@ -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 diff --git a/scripts/captain_codex_poc/test_tools.rb b/scripts/captain_codex_poc/test_tools.rb new file mode 100755 index 000000000..505672102 --- /dev/null +++ b/scripts/captain_codex_poc/test_tools.rb @@ -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.'