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>
194 lines
6.0 KiB
Ruby
Executable File
194 lines
6.0 KiB
Ruby
Executable File
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
|