Skip to main content

lib/jidoka/agent/spec/generation.ex

defmodule Jidoka.Agent.Spec.Generation do
  @moduledoc """
  Provider-facing generation defaults for an agent.

  Generation parameters are intentionally permissive because the supported
  option set varies by model and provider. Jidoka owns the merge shape, while
  ReqLLM/provider clients own final option validation.
  """

  alias Jidoka.Schema

  @known_param_keys [
    :temperature,
    :max_tokens,
    :top_p,
    :presence_penalty,
    :frequency_penalty,
    :tool_choice,
    :system_prompt,
    :timeout,
    :receive_timeout,
    :cache
  ]

  @schema Zoi.struct(
            __MODULE__,
            %{
              params: Zoi.map() |> Zoi.default(%{}),
              provider_options: Zoi.map() |> Zoi.default(%{}),
              extra: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))
  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @spec schema() :: Zoi.schema()
  def schema, do: @schema

  @spec new(keyword() | map()) :: {:ok, t()} | {:error, term()}
  def new(attrs \\ []), do: Schema.parse(@schema, attrs)

  @spec new!(keyword() | map()) :: t()
  def new!(attrs \\ []), do: Schema.parse!(@schema, attrs, "generation")

  @spec from_input(t() | keyword() | map() | nil) :: {:ok, t()} | {:error, term()}
  def from_input(nil), do: new()
  def from_input(%__MODULE__{} = generation), do: new(generation)
  def from_input(input) when is_list(input) or is_map(input), do: new(normalize_input(input))

  @spec to_req_llm_opts(t() | keyword() | map() | nil) :: keyword()
  def to_req_llm_opts(input) do
    case from_input(input) do
      {:ok, %__MODULE__{} = generation} ->
        generation.params
        |> to_keyword()
        |> maybe_put_provider_options(generation.provider_options)

      {:error, reason} ->
        raise ArgumentError, "invalid generation: #{inspect(reason)}"
    end
  end

  defp normalize_input(input) do
    attrs = Schema.normalize_attrs(input)

    if Map.has_key?(attrs, :params) or Map.has_key?(attrs, "params") do
      update_params(attrs)
    else
      %{params: normalize_param_keys(attrs)}
    end
  end

  defp update_params(attrs) do
    params = Map.get(attrs, :params, Map.get(attrs, "params", %{}))

    attrs
    |> Map.delete("params")
    |> Map.put(:params, normalize_param_keys(params))
  end

  defp normalize_param_keys(params) when is_map(params) do
    Map.new(params, fn {key, value} -> {normalize_param_key(key), value} end)
  end

  defp normalize_param_keys(params), do: params

  defp normalize_param_key(key) when is_binary(key) do
    Enum.find(@known_param_keys, key, &(Atom.to_string(&1) == key))
  end

  defp normalize_param_key(key), do: key

  defp maybe_put_provider_options(opts, provider_options) when provider_options == %{}, do: opts

  defp maybe_put_provider_options(opts, provider_options),
    do: Keyword.put(opts, :provider_options, provider_options)

  defp to_keyword(map) when is_map(map) do
    Enum.map(map, fn {key, value} -> {normalize_key(key), value} end)
  end

  defp normalize_key(key) when is_atom(key), do: key

  defp normalize_key(key) when is_binary(key) do
    Enum.find(@known_param_keys, &(Atom.to_string(&1) == key)) ||
      raise ArgumentError,
            "generation param #{inspect(key)} is not a known option; put provider-specific values under provider_options"
  end
end