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:
parent
df56ee8115
commit
928b1ec6b9
@ -89,6 +89,7 @@ Metrics/BlockLength:
|
|||||||
- spec/**/*
|
- spec/**/*
|
||||||
- '**/routes.rb'
|
- '**/routes.rb'
|
||||||
- 'config/environments/*'
|
- 'config/environments/*'
|
||||||
|
- 'lib/tasks/**/*.rake'
|
||||||
- db/schema.rb
|
- db/schema.rb
|
||||||
|
|
||||||
Metrics/ModuleLength:
|
Metrics/ModuleLength:
|
||||||
|
|||||||
57
app/controllers/api/internal/codex_proxy_controller.rb
Normal file
57
app/controllers/api/internal/codex_proxy_controller.rb
Normal 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
|
||||||
@ -749,4 +749,13 @@ Rails.application.routes.draw do
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Routes for testing
|
# Routes for testing
|
||||||
resources :widget_tests, only: [:index] unless Rails.env.production?
|
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
|
end
|
||||||
|
|||||||
@ -13,6 +13,14 @@ trigger_scheduled_items_job:
|
|||||||
class: 'TriggerScheduledItemsJob'
|
class: 'TriggerScheduledItemsJob'
|
||||||
queue: scheduled_jobs
|
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.
|
# Fallback pra draws da roleta revelados cujo callback do front falhou.
|
||||||
# Executa a cada 5 minutos.
|
# Executa a cada 5 minutos.
|
||||||
captain_roleta_notify_revealed_scheduler_job:
|
captain_roleta_notify_revealed_scheduler_job:
|
||||||
|
|||||||
@ -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
|
||||||
19
db/schema.rb
19
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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"
|
t.index ["account_id"], name: "index_captain_brands_on_account_id"
|
||||||
end
|
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|
|
create_table "captain_configurations", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.string "title", default: "Reserva Rápida"
|
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.string "evolution_api_token_iv"
|
||||||
t.jsonb "provider_connection", default: {}
|
t.jsonb "provider_connection", default: {}
|
||||||
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
|
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
|
end
|
||||||
|
|
||||||
create_table "companies", force: :cascade do |t|
|
create_table "companies", force: :cascade do |t|
|
||||||
|
|||||||
12
enterprise/app/jobs/captain/codex/refresh_tokens_job.rb
Normal file
12
enterprise/app/jobs/captain/codex/refresh_tokens_job.rb
Normal 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
|
||||||
32
enterprise/app/models/captain/codex_credential.rb
Normal file
32
enterprise/app/models/captain/codex_credential.rb
Normal 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
|
||||||
171
enterprise/app/services/captain/codex/auth_service.rb
Normal file
171
enterprise/app/services/captain/codex/auth_service.rb
Normal 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
|
||||||
125
enterprise/app/services/captain/codex/client.rb
Normal file
125
enterprise/app/services/captain/codex/client.rb
Normal 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
|
||||||
181
enterprise/app/services/captain/codex/translator.rb
Normal file
181
enterprise/app/services/captain/codex/translator.rb
Normal 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
|
||||||
81
lib/tasks/captain_codex.rake
Normal file
81
lib/tasks/captain_codex.rake
Normal 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
|
||||||
204
spec/enterprise/services/captain/codex/translator_spec.rb
Normal file
204
spec/enterprise/services/captain/codex/translator_spec.rb
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user