From 928b1ec6b910e57815a3ff3f1079ff225059027d Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 22 Apr 2026 15:07:01 -0300 Subject: [PATCH] feat(captain): Codex OAuth auth module + proxy controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .rubocop.yml | 1 + .../api/internal/codex_proxy_controller.rb | 57 +++++ config/routes.rb | 9 + config/schedule.yml | 8 + ...145733_create_captain_codex_credentials.rb | 18 ++ db/schema.rb | 19 +- .../jobs/captain/codex/refresh_tokens_job.rb | 12 ++ .../app/models/captain/codex_credential.rb | 32 +++ .../services/captain/codex/auth_service.rb | 171 +++++++++++++++ .../app/services/captain/codex/client.rb | 125 +++++++++++ .../app/services/captain/codex/translator.rb | 181 ++++++++++++++++ lib/tasks/captain_codex.rake | 81 +++++++ .../services/captain/codex/translator_spec.rb | 204 ++++++++++++++++++ 13 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/internal/codex_proxy_controller.rb create mode 100644 db/migrate/20260422145733_create_captain_codex_credentials.rb create mode 100644 enterprise/app/jobs/captain/codex/refresh_tokens_job.rb create mode 100644 enterprise/app/models/captain/codex_credential.rb create mode 100644 enterprise/app/services/captain/codex/auth_service.rb create mode 100644 enterprise/app/services/captain/codex/client.rb create mode 100644 enterprise/app/services/captain/codex/translator.rb create mode 100644 lib/tasks/captain_codex.rake create mode 100644 spec/enterprise/services/captain/codex/translator_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index e4b5320a7..d930b34d4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -89,6 +89,7 @@ Metrics/BlockLength: - spec/**/* - '**/routes.rb' - 'config/environments/*' + - 'lib/tasks/**/*.rake' - db/schema.rb Metrics/ModuleLength: diff --git a/app/controllers/api/internal/codex_proxy_controller.rb b/app/controllers/api/internal/codex_proxy_controller.rb new file mode 100644 index 000000000..40889e3ff --- /dev/null +++ b/app/controllers/api/internal/codex_proxy_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 63117679f..4b3451d44 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/schedule.yml b/config/schedule.yml index 667882927..835c4bc9f 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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: diff --git a/db/migrate/20260422145733_create_captain_codex_credentials.rb b/db/migrate/20260422145733_create_captain_codex_credentials.rb new file mode 100644 index 000000000..1e81029cb --- /dev/null +++ b/db/migrate/20260422145733_create_captain_codex_credentials.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 47d05f5d3..f461d0076 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/enterprise/app/jobs/captain/codex/refresh_tokens_job.rb b/enterprise/app/jobs/captain/codex/refresh_tokens_job.rb new file mode 100644 index 000000000..a96909374 --- /dev/null +++ b/enterprise/app/jobs/captain/codex/refresh_tokens_job.rb @@ -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 diff --git a/enterprise/app/models/captain/codex_credential.rb b/enterprise/app/models/captain/codex_credential.rb new file mode 100644 index 000000000..6abe936f0 --- /dev/null +++ b/enterprise/app/models/captain/codex_credential.rb @@ -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 diff --git a/enterprise/app/services/captain/codex/auth_service.rb b/enterprise/app/services/captain/codex/auth_service.rb new file mode 100644 index 000000000..aa3a137a1 --- /dev/null +++ b/enterprise/app/services/captain/codex/auth_service.rb @@ -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 diff --git a/enterprise/app/services/captain/codex/client.rb b/enterprise/app/services/captain/codex/client.rb new file mode 100644 index 000000000..e8b378fb6 --- /dev/null +++ b/enterprise/app/services/captain/codex/client.rb @@ -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 diff --git a/enterprise/app/services/captain/codex/translator.rb b/enterprise/app/services/captain/codex/translator.rb new file mode 100644 index 000000000..8a703322a --- /dev/null +++ b/enterprise/app/services/captain/codex/translator.rb @@ -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 diff --git a/lib/tasks/captain_codex.rake b/lib/tasks/captain_codex.rake new file mode 100644 index 000000000..7e6a713dd --- /dev/null +++ b/lib/tasks/captain_codex.rake @@ -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 diff --git a/spec/enterprise/services/captain/codex/translator_spec.rb b/spec/enterprise/services/captain/codex/translator_spec.rb new file mode 100644 index 000000000..22e448ae7 --- /dev/null +++ b/spec/enterprise/services/captain/codex/translator_spec.rb @@ -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