lib/openagents/voice/open_ai/call_client.ex

main at 58e6347eeb72 · 3 KB

defmodule OpenAgents.Voice.OpenAI.CallClient do
  @moduledoc """
  OpenAI unified Realtime call adapter.

  Standard credentials and protected session configuration terminate here.
  The browser receives only the SDP answer.
  """

  @behaviour OpenAgents.Voice.CallProvider

  alias OpenAgents.Voice.{CallAdmission, Config}

  @endpoint "https://api.openai.com/v1/realtime/calls"
  @maximum_sdp_bytes 65_536

  @impl true
  def create(sdp_offer, safety_identifier, %Config{} = config) do
    create(sdp_offer, safety_identifier, config, [])
  end

  @doc false
  def create(sdp_offer, safety_identifier, %Config{} = config, options)
      when is_list(options) do
    with :ok <- validate_enabled(config),
         :ok <- validate_sdp(sdp_offer),
         :ok <- validate_safety_identifier(safety_identifier),
         {:ok, api_key} <- fetch_api_key(options),
         {:ok, response} <- request(sdp_offer, safety_identifier, config, api_key, options) do
      decode_response(response)
    end
  end

  defp validate_enabled(%Config{enabled?: true}), do: :ok
  defp validate_enabled(%Config{}), do: {:error, :voice_disabled}

  defp validate_sdp("v=0" <> _rest = sdp) when byte_size(sdp) <= @maximum_sdp_bytes, do: :ok
  defp validate_sdp(_sdp), do: {:error, :invalid_sdp}

  defp validate_safety_identifier(identifier)
       when is_binary(identifier) and byte_size(identifier) == 64,
       do: :ok

  defp validate_safety_identifier(_identifier), do: {:error, :invalid_safety_identifier}

  defp fetch_api_key(options) do
    case Keyword.fetch(options, :api_key) do
      {:ok, key} when is_binary(key) and byte_size(key) > 0 ->
        {:ok, key}

      _not_supplied ->
        case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
          {:ok, key} -> {:ok, key}
          {:error, :not_configured} -> {:error, :missing_api_key}
        end
    end
  end

  defp request(sdp_offer, safety_identifier, config, api_key, options) do
    request_options = Keyword.get(options, :request_options, [])

    base_options = [
      auth: {:bearer, api_key},
      headers: [
        {"accept", "application/sdp"},
        {"openai-safety-identifier", safety_identifier}
      ],
      form_multipart: [
        sdp: sdp_offer,
        session: Jason.encode!(Config.session_payload(config))
      ],
      receive_timeout: 30_000,
      retry: false,
      decode_body: false
    ]

    case Req.post(@endpoint, Keyword.merge(base_options, request_options)) do
      {:ok, response} ->
        {:ok, response}

      {:error, %Req.TransportError{reason: reason}} when is_atom(reason) ->
        {:error, {:transport, reason}}

      {:error, _error} ->
        {:error, {:transport, :request_failed}}
    end
  end

  defp decode_response(%Req.Response{status: status, body: answer_sdp} = response)
       when status in 200..299 and is_binary(answer_sdp) do
    with :ok <- validate_answer_sdp(answer_sdp),
         {:ok, call_id} <- call_id(response) do
      {:ok, %CallAdmission{provider_session_id: call_id, answer_sdp: answer_sdp}}
    end
  end

  defp decode_response(%Req.Response{status: status}) when status in 400..599,
    do: {:error, {:http_status, status}}

  defp decode_response(%Req.Response{}), do: {:error, :invalid_provider_response}

  defp validate_answer_sdp("v=0" <> _rest = sdp) when byte_size(sdp) <= @maximum_sdp_bytes,
    do: :ok

  defp validate_answer_sdp(_sdp), do: {:error, :invalid_provider_response}

  defp call_id(response) do
    case Req.Response.get_header(response, "location") do
      [location] -> parse_call_location(location)
      _missing_or_repeated -> {:error, :missing_call_id}
    end
  end

  defp parse_call_location(location) when is_binary(location) do
    case String.split(location, "/", trim: true) do
      ["v1", "realtime", "calls", "rtc_" <> _rest = call_id]
      when byte_size(call_id) <= 256 ->
        {:ok, call_id}

      _invalid ->
        {:error, :invalid_call_id}
    end
  end
end