Current section
Files
Jump to
Current section
Files
lib/codex/app_server/connection.ex
defmodule Codex.AppServer.Connection do
@moduledoc false
use GenServer
require Logger
alias Codex.AppServer.Protocol
alias Codex.AppServer.Subprocess.Erlexec
alias Codex.Options
@default_init_timeout_ms 10_000
@default_request_timeout_ms 30_000
defmodule State do
@moduledoc false
defstruct [
:codex_opts,
:subprocess_mod,
:subprocess_opts,
:subprocess_pid,
:os_pid,
:phase,
:next_id,
:stdout_buffer,
:stderr,
:pending,
:ready_waiters,
:subscribers,
:subscriber_refs
]
end
@type connection :: pid()
@type request_id :: integer() | String.t()
@spec start_link(Options.t(), keyword()) :: GenServer.on_start()
def start_link(%Options{} = codex_opts, opts) when is_list(opts) do
GenServer.start_link(__MODULE__, {codex_opts, opts})
end
@spec start_link({Options.t(), keyword()}) :: GenServer.on_start()
def start_link({%Options{} = codex_opts, opts}) when is_list(opts) do
start_link(codex_opts, opts)
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(%Options{} = codex_opts) do
start_link(codex_opts, [])
end
@spec await_ready(connection(), pos_integer()) :: :ok | {:error, term()}
def await_ready(conn, timeout_ms)
when is_pid(conn) and is_integer(timeout_ms) and timeout_ms > 0 do
GenServer.call(conn, :await_ready, timeout_ms)
end
@spec subscribe(connection(), keyword()) :: :ok | {:error, term()}
def subscribe(conn, opts \\ []) when is_pid(conn) and is_list(opts) do
GenServer.call(conn, {:subscribe, self(), opts})
end
@spec unsubscribe(connection()) :: :ok
def unsubscribe(conn) when is_pid(conn) do
GenServer.call(conn, {:unsubscribe, self()})
end
@spec request(connection(), String.t(), map() | list() | nil, keyword()) ::
{:ok, term()} | {:error, term()}
def request(conn, method, params \\ nil, opts \\ [])
when is_pid(conn) and is_binary(method) and is_list(opts) do
timeout_ms = Keyword.get(opts, :timeout_ms, @default_request_timeout_ms)
GenServer.call(conn, {:request, method, params, timeout_ms}, timeout_ms + 1_000)
end
@spec respond(connection(), request_id(), map()) :: :ok | {:error, term()}
def respond(conn, id, result) when is_pid(conn) and is_map(result) do
GenServer.call(conn, {:respond, id, result})
end
@impl true
def init({%Options{} = codex_opts, opts}) do
subprocess_mod = resolve_subprocess_module(opts)
subprocess_opts = resolve_subprocess_opts(opts)
init_timeout_ms = Keyword.get(opts, :init_timeout_ms, @default_init_timeout_ms)
client_name = Keyword.get(opts, :client_name, "codex_sdk")
client_title = Keyword.get(opts, :client_title)
client_version = Keyword.get(opts, :client_version, default_client_version())
with :ok <- ensure_erlexec_started(subprocess_mod),
{:ok, command} <- build_command(codex_opts),
{:ok, subprocess_pid, os_pid} <-
subprocess_mod.start(command, start_opts(codex_opts), subprocess_opts),
:ok <-
subprocess_mod.send(
subprocess_pid,
Protocol.encode_request(0, "initialize", %{
"clientInfo" =>
%{"name" => client_name, "version" => client_version}
|> put_optional("title", client_title)
}),
subprocess_opts
) do
timer_ref = Process.send_after(self(), {:request_timeout, 0}, init_timeout_ms)
{:ok,
%State{
codex_opts: codex_opts,
subprocess_mod: subprocess_mod,
subprocess_opts: subprocess_opts,
subprocess_pid: subprocess_pid,
os_pid: os_pid,
phase: :initializing,
next_id: 1,
stdout_buffer: "",
stderr: [],
pending: %{
0 => %{
from: :init,
method: "initialize",
timeout_ms: init_timeout_ms,
timer_ref: timer_ref
}
},
ready_waiters: [],
subscribers: %{},
subscriber_refs: %{}
}}
else
{:error, _} = error ->
error
other ->
{:stop, other}
end
end
@impl true
def handle_call(:await_ready, _from, %State{phase: :ready} = state) do
{:reply, :ok, state}
end
def handle_call(:await_ready, from, %State{} = state) do
{:noreply, %State{state | ready_waiters: [from | state.ready_waiters]}}
end
def handle_call({:subscribe, pid, opts}, _from, %State{} = state) do
ref = Process.monitor(pid)
filters = %{
thread_id: Keyword.get(opts, :thread_id),
methods: Keyword.get(opts, :methods)
}
{:reply, :ok,
%State{
state
| subscribers: Map.put(state.subscribers, pid, filters),
subscriber_refs: Map.put(state.subscriber_refs, ref, pid)
}}
end
def handle_call({:unsubscribe, pid}, _from, %State{} = state) do
{refs_to_drop, subscriber_refs} =
Enum.reduce(state.subscriber_refs, {[], %{}}, fn {ref, sub_pid}, {refs, acc} ->
if sub_pid == pid, do: {[ref | refs], acc}, else: {refs, Map.put(acc, ref, sub_pid)}
end)
Enum.each(refs_to_drop, &Process.demonitor(&1, [:flush]))
{:reply, :ok,
%State{
state
| subscribers: Map.delete(state.subscribers, pid),
subscriber_refs: subscriber_refs
}}
end
def handle_call({:respond, id, result}, _from, %State{} = state) do
with :ok <- send_iolist(state, Protocol.encode_response(id, result)) do
{:reply, :ok, state}
end
end
def handle_call({:request, _method, _params, _timeout_ms}, _from, %State{phase: phase} = state)
when phase != :ready do
{:reply, {:error, :not_ready}, state}
end
def handle_call({:request, method, params, timeout_ms}, from, %State{} = state) do
id = state.next_id
timer_ref = Process.send_after(self(), {:request_timeout, id}, timeout_ms)
pending =
Map.put(state.pending, id, %{
from: from,
method: method,
timeout_ms: timeout_ms,
timer_ref: timer_ref
})
case send_iolist(state, Protocol.encode_request(id, method, params)) do
:ok ->
{:noreply, %State{state | next_id: id + 1, pending: pending}}
{:error, reason} ->
_ = Process.cancel_timer(timer_ref)
{:reply, {:error, reason}, %State{state | pending: state.pending}}
end
end
@impl true
def handle_info({:stdout, os_pid, chunk}, %State{os_pid: os_pid} = state) do
{messages, buffer, non_json} = Protocol.decode_lines(state.stdout_buffer, chunk)
Enum.each(non_json, fn raw ->
Logger.debug("Ignoring non-JSON app-server output: #{inspect(raw)}")
end)
state =
Enum.reduce(messages, %State{state | stdout_buffer: buffer}, fn msg, acc ->
handle_incoming_message(acc, msg)
end)
{:noreply, state}
end
def handle_info({:stderr, os_pid, chunk}, %State{os_pid: os_pid} = state) do
{:noreply, %State{state | stderr: [chunk | state.stderr]}}
end
def handle_info({:DOWN, os_pid, :process, _pid, reason}, %State{os_pid: os_pid} = state) do
Logger.warning("codex app-server exited: #{inspect(reason)}")
{:stop, {:app_server_down, reason}, state}
end
def handle_info({:DOWN, ref, :process, pid, _reason}, %State{} = state) do
state =
case Map.pop(state.subscriber_refs, ref) do
{nil, _refs} ->
state
{^pid, refs} ->
%State{state | subscriber_refs: refs, subscribers: Map.delete(state.subscribers, pid)}
end
{:noreply, state}
end
def handle_info({:request_timeout, id}, %State{} = state) do
case Map.pop(state.pending, id) do
{nil, _pending} ->
{:noreply, state}
{%{from: :init, timeout_ms: timeout_ms}, pending} ->
Enum.each(state.ready_waiters, fn from ->
GenServer.reply(from, {:error, {:init_timeout, timeout_ms}})
end)
stop_subprocess(state)
{:stop, {:init_timeout, timeout_ms}, %State{state | pending: pending}}
{%{from: from, method: method, timeout_ms: timeout_ms}, pending} ->
GenServer.reply(from, {:error, {:timeout, method, timeout_ms}})
{:noreply, %State{state | pending: pending}}
end
end
def handle_info(_msg, %State{} = state) do
{:noreply, state}
end
@impl true
def terminate(_reason, %State{} = state) do
stop_subprocess(state)
:ok
end
defp handle_incoming_message(%State{} = state, msg) when is_map(msg) do
case Protocol.message_type(msg) do
:notification ->
method = Map.get(msg, "method")
params = Map.get(msg, "params") || %{}
broadcast_notification(state, method, params)
:request ->
id = Map.get(msg, "id")
method = Map.get(msg, "method")
params = Map.get(msg, "params") || %{}
broadcast_request(state, id, method, params)
:response ->
handle_response(state, Map.get(msg, "id"), {:ok, Map.get(msg, "result")})
:error ->
handle_response(state, Map.get(msg, "id"), {:error, Map.get(msg, "error")})
:unknown ->
Logger.debug("Ignoring unknown JSON-RPC message: #{inspect(msg)}")
state
end
end
defp handle_response(%State{} = state, id, reply) do
case Map.pop(state.pending, id) do
{nil, _pending} ->
Logger.debug("Ignoring response for unknown request id: #{inspect(id)}")
state
{%{from: :init, timer_ref: timer_ref}, pending} ->
_ = Process.cancel_timer(timer_ref)
_ = send_iolist(state, Protocol.encode_notification("initialized"))
Enum.each(state.ready_waiters, fn from -> GenServer.reply(from, :ok) end)
%State{state | phase: :ready, pending: pending, ready_waiters: []}
{%{from: from, timer_ref: timer_ref}, pending} ->
_ = Process.cancel_timer(timer_ref)
GenServer.reply(from, reply)
%State{state | pending: pending}
end
end
defp broadcast_notification(%State{} = state, method, params) do
Enum.each(state.subscribers, fn {pid, filters} ->
if subscriber_match?(filters, method, params) do
send(pid, {:codex_notification, method, params})
end
end)
state
end
defp broadcast_request(%State{} = state, id, method, params) do
Enum.each(state.subscribers, fn {pid, filters} ->
if subscriber_match?(filters, method, params) do
send(pid, {:codex_request, id, method, params})
end
end)
state
end
defp subscriber_match?(%{methods: nil, thread_id: nil}, _method, _params), do: true
defp subscriber_match?(filters, method, params) do
method_ok? =
case filters.methods do
nil -> true
methods when is_list(methods) -> method in methods
end
thread_ok? =
case filters.thread_id do
nil ->
true
thread_id when is_binary(thread_id) ->
params_thread_id =
Map.get(params, "threadId") || Map.get(params, "thread_id") ||
Map.get(params, :thread_id)
thread_id == params_thread_id
end
method_ok? and thread_ok?
end
defp resolve_subprocess_module(opts) do
case Keyword.get(opts, :subprocess) do
nil -> Erlexec
{module, _} when is_atom(module) -> module
module when is_atom(module) -> module
other -> raise ArgumentError, "invalid subprocess option: #{inspect(other)}"
end
end
defp resolve_subprocess_opts(opts) do
case Keyword.get(opts, :subprocess) do
{_module, sub_opts} when is_list(sub_opts) -> sub_opts
_ -> []
end
end
defp ensure_erlexec_started(Erlexec) do
case Application.ensure_all_started(:erlexec) do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
{:error, {:erlexec, {:already_started, _}}} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp ensure_erlexec_started(_other), do: :ok
defp build_command(%Options{} = opts) do
with {:ok, binary_path} <- Options.codex_path(opts) do
command = Enum.map([binary_path, "app-server"], &to_charlist/1)
{:ok, command}
end
end
defp start_opts(%Options{} = opts) do
env = build_env(opts)
base = [:stdin, {:stdout, self()}, {:stderr, self()}, :monitor]
if env == [] do
base
else
[{:env, env} | base]
end
end
defp build_env(%Options{} = opts) do
base_env =
[]
|> maybe_put_key("CODEX_API_KEY", opts.api_key)
|> maybe_put_key("OPENAI_API_KEY", opts.api_key)
|> maybe_put_key(
"OPENAI_BASE_URL",
if(opts.base_url != "https://api.openai.com/v1", do: opts.base_url, else: nil)
)
|> Map.new()
Enum.map(base_env, fn {key, value} -> {key, value} end)
end
defp maybe_put_key(env, _key, nil), do: env
defp maybe_put_key(env, _key, ""), do: env
defp maybe_put_key(env, key, value), do: [{key, value} | env]
defp send_iolist(%State{} = state, data) do
state.subprocess_mod.send(state.subprocess_pid, data, state.subprocess_opts)
end
defp stop_subprocess(%State{} = state) do
state.subprocess_mod.stop(state.subprocess_pid, state.subprocess_opts)
end
defp default_client_version do
case Application.spec(:codex_sdk, :vsn) do
nil -> "0.0.0"
vsn -> to_string(vsn)
end
end
defp put_optional(map, _key, nil), do: map
defp put_optional(map, key, value), do: Map.put(map, key, value)
end