lib/openagents/voice/config.ex

58e6347eeb72 · 6 KB

defmodule OpenAgents.Voice.Config do
  @moduledoc """
  Validated admission configuration for Sarah's spoken surface.

  Voice identity is selected before a Realtime call is created. There is no
  implicit provider, model, voice, or architecture fallback.
  """

  @supported_architectures [:openai_realtime]
  @supported_voices ~w(marin cedar)

  @enforce_keys [
    :enabled?,
    :architecture,
    :provider,
    :model,
    :voice,
    :reasoning_effort,
    :maximum_session_seconds
  ]
  defstruct @enforce_keys ++
              [instructions: nil, tools: []]

  @type t :: %__MODULE__{
          enabled?: boolean(),
          architecture: :openai_realtime,
          provider: String.t(),
          model: String.t(),
          voice: String.t(),
          reasoning_effort: String.t(),
          maximum_session_seconds: pos_integer(),
          instructions: String.t() | nil,
          tools: [map()]
        }

  @spec current!() :: t()
  def current! do
    :openagents
    |> Application.fetch_env!(:voice)
    |> build!()
  end

  @spec validate_boot!() :: :ok
  def validate_boot! do
    :ok =
      current!()
      |> validate_runtime!()

    validate_attempt_limits!()
  end

  @spec validate_runtime!(t(), (String.t() -> String.t() | nil)) :: :ok
  def validate_runtime!(config, get_environment \\ &configured_secret/1)

  def validate_runtime!(%__MODULE__{enabled?: false}, _get_environment), do: :ok

  def validate_runtime!(%__MODULE__{enabled?: true}, get_environment)
      when is_function(get_environment, 1) do
    case get_environment.("OPENAI_API_KEY") do
      key when is_binary(key) and byte_size(key) > 0 -> :ok
      _missing -> raise ArgumentError, "enabled Sarah voice requires OPENAI_API_KEY"
    end
  end

  @spec build!(keyword()) :: t()
  def build!(attributes) when is_list(attributes) do
    config = %__MODULE__{
      enabled?: Keyword.fetch!(attributes, :enabled),
      architecture: Keyword.fetch!(attributes, :architecture),
      provider: Keyword.fetch!(attributes, :provider),
      model: Keyword.fetch!(attributes, :model),
      voice: Keyword.fetch!(attributes, :voice),
      reasoning_effort: Keyword.fetch!(attributes, :reasoning_effort),
      maximum_session_seconds: Keyword.fetch!(attributes, :maximum_session_seconds)
    }

    case validate(config) do
      :ok -> config
      {:error, reason} -> raise ArgumentError, "invalid Sarah voice configuration: #{reason}"
    end
  end

  def build!(_attributes), do: raise(ArgumentError, "invalid Sarah voice configuration")

  @spec session_payload(t()) :: map()
  def session_payload(%__MODULE__{} = config) do
    payload = %{
      type: "realtime",
      model: config.model,
      reasoning: %{effort: config.reasoning_effort},
      parallel_tool_calls: false,
      audio: %{
        input: %{
          noise_reduction: %{type: "near_field"},
          transcription: %{model: "gpt-4o-mini-transcribe", language: "en"},
          turn_detection: %{
            type: "semantic_vad",
            create_response: false,
            interrupt_response: true
          }
        },
        output: %{voice: config.voice}
      }
    }

    payload
    |> maybe_put(:instructions, config.instructions)
    |> maybe_put_tools(config.tools)
  end

  @spec with_context(t(), String.t(), [map()]) :: t()
  def with_context(%__MODULE__{} = config, instructions, tools)
      when is_binary(instructions) and byte_size(instructions) in 1..65_536 and is_list(tools) and
             length(tools) <= 64 do
    %{config | instructions: instructions, tools: tools}
  end

  def with_context(%__MODULE__{}, _instructions, _tools) do
    raise ArgumentError, "invalid Realtime context"
  end

  defp validate(%__MODULE__{} = config) do
    cond do
      not is_boolean(config.enabled?) -> {:error, "enabled must be boolean"}
      config.architecture not in @supported_architectures -> {:error, "unsupported architecture"}
      config.provider != "openai" -> {:error, "unsupported provider"}
      config.model != "gpt-realtime-2.1" -> {:error, "unadmitted Realtime model"}
      config.voice not in @supported_voices -> {:error, "unadmitted voice artifact"}
      config.reasoning_effort not in ~w(low medium high) -> {:error, "invalid reasoning effort"}
      config.maximum_session_seconds not in 1..3_300 -> {:error, "invalid session duration"}
      true -> :ok
    end
  end

  defp configured_secret("OPENAI_API_KEY") do
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
      {:ok, value} -> value
      {:error, :not_configured} -> nil
    end
  end

  defp maybe_put(payload, _key, nil), do: payload
  defp maybe_put(payload, key, value), do: Map.put(payload, key, value)

  defp maybe_put_tools(payload, []), do: payload

  defp maybe_put_tools(payload, tools) when is_list(tools) do
    payload
    |> Map.put(:tools, tools)
    |> Map.put(:tool_choice, "auto")
  end

  defp validate_attempt_limits! do
    attempt_limit = Application.fetch_env!(:openagents, :voice_attempt_limit)
    window_seconds = Application.fetch_env!(:openagents, :voice_attempt_window_seconds)
    concurrent_sessions = Application.fetch_env!(:openagents, :voice_maximum_concurrent_sessions)
    maximum_session_tokens = Application.fetch_env!(:openagents, :voice_maximum_session_tokens)

    maximum_response_output_tokens =
      Application.fetch_env!(:openagents, :voice_maximum_response_output_tokens)

    maximum_cost =
      Application.fetch_env!(:openagents, :voice_maximum_estimated_cost_microusd)

    retention_days = Application.fetch_env!(:openagents, :voice_operational_retention_days)

    if attempt_limit not in 1..30 do
      raise ArgumentError, "invalid Sarah voice attempt limit"
    end

    if window_seconds not in 60..86_400 do
      raise ArgumentError, "invalid Sarah voice attempt window"
    end

    if concurrent_sessions not in 1..100 do
      raise ArgumentError, "invalid Sarah voice concurrent-session limit"
    end

    if maximum_session_tokens not in 1_000..100_000_000 do
      raise ArgumentError, "invalid Sarah voice session token budget"
    end

    if maximum_response_output_tokens not in 128..32_000 do
      raise ArgumentError, "invalid Sarah voice response output-token budget"
    end

    if maximum_cost not in 10_000..1_000_000_000 do
      raise ArgumentError, "invalid Sarah voice session cost budget"
    end

    if retention_days not in 1..365 do
      raise ArgumentError, "invalid Sarah voice operational retention"
    end

    :ok
  end
end