iachat/enterprise/app/services/captain/codex/translator.rb
Rodribm10 928b1ec6b9 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>
2026-04-22 15:07:01 -03:00

182 lines
5.6 KiB
Ruby

# 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