defmodule Codex.Options do @moduledoc """ Global configuration for Codex interactions. Options are built from caller-supplied values merged with environment defaults. ## Models newer than the bundled registry `Codex.Models` resolves model metadata from a bundled, vendored catalog (via `CliSubprocessCore.ModelRegistry`). A model id that's newer than the bundled catalog is passed through as-is by default — matching the installed `codex` CLI, which does not itself validate `--model` against this catalog. A warning is logged when this happens. Pass `allow_unknown_model: false` to restore strict rejection of any model id not present in the bundled catalog (useful for catching typos early). """ require Logger alias CliSubprocessCore.{CommandSpec, ExecutionSurface, ModelInput, ProviderCLI} alias Codex.Auth alias Codex.Config.BaseURL alias Codex.Config.OptionNormalizers alias Codex.Config.Overrides alias Codex.GovernedAuthority alias Codex.Models @derive {Inspect, except: [:api_key]} @enforce_keys [] defstruct api_key: nil, base_url: BaseURL.default(), codex_path_override: nil, execution_surface: %ExecutionSurface{}, telemetry_prefix: [:codex], model_payload: nil, model: Models.default_model(), reasoning_effort: Models.default_reasoning_effort(), model_personality: nil, model_reasoning_summary: nil, model_verbosity: nil, model_context_window: nil, model_supports_reasoning_summaries: nil, model_auto_compact_token_limit: nil, review_model: nil, history_persistence: nil, history_max_bytes: nil, hide_agent_reasoning: false, tool_output_token_limit: nil, agent_max_threads: nil, config_overrides: [], governed_authority: nil, allow_unknown_model: true @typep config_override_value_scalar :: String.t() | boolean() | integer() | float() @typep config_override_value :: config_override_value_scalar() | [config_override_value()] | %{optional(String.t() | atom()) => config_override_value()} @type t :: %__MODULE__{ api_key: String.t() | nil, base_url: String.t(), codex_path_override: String.t() | nil, execution_surface: ExecutionSurface.t(), telemetry_prefix: [atom()], model_payload: CliSubprocessCore.ModelRegistry.selection() | nil, model: String.t() | nil, reasoning_effort: Models.reasoning_effort() | nil, model_personality: Codex.Protocol.ConfigTypes.personality() | nil, model_reasoning_summary: String.t() | nil, model_verbosity: String.t() | nil, model_context_window: pos_integer() | nil, model_supports_reasoning_summaries: boolean() | nil, model_auto_compact_token_limit: pos_integer() | nil, review_model: String.t() | nil, history_persistence: String.t() | nil, history_max_bytes: non_neg_integer() | nil, hide_agent_reasoning: boolean(), tool_output_token_limit: pos_integer() | nil, agent_max_threads: pos_integer() | nil, config_overrides: [ String.t() | {String.t(), config_override_value()} ], governed_authority: map() | nil, allow_unknown_model: boolean() } @doc """ Builds a validated options struct. API keys are optional. When omitted, the Codex CLI relies on your existing `codex` login (ChatGPT tokens stored in `auth.json`). """ @spec new(map() | keyword()) :: {:ok, t()} | {:error, term()} def new(attrs \\ %{}) do attrs = Map.new(attrs) with {:ok, governed_authority} <- fetch_governed_authority(attrs), {:ok, api_key} <- fetch_api_key(attrs, governed_authority), {:ok, base_url} <- fetch_base_url(attrs, governed_authority), {:ok, override} <- fetch_codex_path_override(attrs), {:ok, execution_surface} <- fetch_execution_surface(attrs), {:ok, telemetry_prefix} <- fetch_telemetry_prefix(attrs), {:ok, allow_unknown_model} <- fetch_allow_unknown_model(attrs), {:ok, model_input} <- normalize_model_input(attrs, governed_authority, allow_unknown_model), model_payload = model_input.selection, :ok = warn_if_unregistered_model(model_payload), normalized_attrs = model_input.attrs, {:ok, model} <- fetch_model(model_payload), {:ok, reasoning_effort} <- fetch_reasoning_effort(model_payload), {:ok, model_personality} <- fetch_model_personality(normalized_attrs), {:ok, reasoning_summary} <- fetch_reasoning_summary(normalized_attrs), {:ok, model_verbosity} <- fetch_model_verbosity(normalized_attrs), {:ok, model_context_window} <- fetch_model_context_window(normalized_attrs), {:ok, supports_reasoning_summaries} <- fetch_supports_reasoning_summaries(normalized_attrs), {:ok, model_auto_compact_token_limit} <- fetch_model_auto_compact_token_limit(normalized_attrs), {:ok, review_model} <- fetch_review_model(normalized_attrs), {:ok, history_persistence} <- fetch_history_persistence(normalized_attrs), {:ok, history_max_bytes} <- fetch_history_max_bytes(normalized_attrs), {:ok, hide_agent_reasoning} <- fetch_hide_agent_reasoning(normalized_attrs), {:ok, tool_output_token_limit} <- fetch_tool_output_token_limit(normalized_attrs), {:ok, agent_max_threads} <- fetch_agent_max_threads(normalized_attrs), {:ok, config_overrides} <- fetch_config_overrides(normalized_attrs), :ok <- GovernedAuthority.reject_config_overrides( governed_authority, config_overrides, :options ), :ok <- GovernedAuthority.validate_command_override( governed_authority, override, :options ) do {:ok, %__MODULE__{ api_key: api_key, base_url: base_url, codex_path_override: override, execution_surface: execution_surface, telemetry_prefix: telemetry_prefix, model_payload: model_payload, model: model, reasoning_effort: reasoning_effort, model_personality: model_personality, model_reasoning_summary: reasoning_summary, model_verbosity: model_verbosity, model_context_window: model_context_window, model_supports_reasoning_summaries: supports_reasoning_summaries, model_auto_compact_token_limit: model_auto_compact_token_limit, review_model: review_model, history_persistence: history_persistence, history_max_bytes: history_max_bytes, hide_agent_reasoning: hide_agent_reasoning, tool_output_token_limit: tool_output_token_limit, agent_max_threads: agent_max_threads, config_overrides: config_overrides, governed_authority: governed_authority, allow_unknown_model: allow_unknown_model }} end end @doc """ Determines a stable command spec for launching `codex`. Local execution surfaces honor the shared core discovery policy: 1. Explicit override on the struct. 2. `CODEX_PATH`. 3. Shared local discovery through `CliSubprocessCore.ProviderCLI`. Remote execution surfaces intentionally avoid local-path discovery. They use an explicit override when supplied, otherwise they fall back to the remote provider command name (`codex`). """ @spec codex_command_spec(t(), ExecutionSurface.t() | map() | keyword() | nil) :: {:ok, CommandSpec.t()} | {:error, term()} def codex_command_spec(%__MODULE__{} = opts, execution_surface_override \\ nil) do with {:ok, execution_surface} <- effective_command_execution_surface(opts, execution_surface_override) do resolve_codex_command_spec(opts, execution_surface) end end defp resolve_codex_command_spec( %__MODULE__{codex_path_override: override}, %ExecutionSurface{} = execution_surface ) when is_binary(override) do resolve_provider_cli([command: override], execution_surface) end defp resolve_codex_command_spec(%__MODULE__{}, %ExecutionSurface{} = execution_surface) do resolve_provider_cli([], execution_surface) end defp resolve_provider_cli(provider_opts, %ExecutionSurface{} = execution_surface) when is_list(provider_opts) do case ProviderCLI.resolve(:codex, provider_opts, execution_surface: execution_surface) do {:ok, %CommandSpec{} = spec} -> {:ok, spec} {:error, %ProviderCLI.Error{kind: :cli_not_found}} -> {:error, :codex_binary_not_found} {:error, reason} -> {:error, reason} end end @doc """ Determines the stable executable path to `codex`. This returns the resolved program from `codex_command_spec/1`. Internal launchers should prefer `codex_command_spec/1` so argv prefixes remain available when needed. """ @spec codex_path(t()) :: {:ok, String.t()} | {:error, term()} def codex_path(%__MODULE__{} = opts) do with {:ok, %CommandSpec{program: program}} <- codex_command_spec(opts) do {:ok, program} end end @doc false @spec execution_model(t()) :: String.t() | nil def execution_model(%__MODULE__{model: model} = opts) when is_binary(model) and model != "" do if implicit_default_model?(opts), do: nil, else: model end def execution_model(%__MODULE__{}), do: nil @doc false @spec normalize_execution_surface(term()) :: {:ok, ExecutionSurface.t()} | {:error, term()} def normalize_execution_surface(nil), do: {:ok, %ExecutionSurface{}} def normalize_execution_surface(%ExecutionSurface{} = execution_surface), do: {:ok, execution_surface} def normalize_execution_surface(execution_surface) when is_list(execution_surface) do ExecutionSurface.new(execution_surface) end def normalize_execution_surface(%{} = execution_surface) do execution_surface |> execution_surface_attrs() |> ExecutionSurface.new() end def normalize_execution_surface(other), do: {:error, {:invalid_execution_surface, other}} @doc false @spec execution_surface_options(t() | ExecutionSurface.t() | nil) :: keyword() def execution_surface_options(%__MODULE__{execution_surface: execution_surface}) do execution_surface_options(execution_surface) end def execution_surface_options(%ExecutionSurface{} = execution_surface) do execution_surface |> ExecutionSurface.surface_metadata() |> Keyword.put(:transport_options, execution_surface.transport_options) end def execution_surface_options(nil), do: [] defp fetch_governed_authority(attrs), do: GovernedAuthority.fetch(attrs) defp fetch_api_key(attrs, nil) do case normalize_string(pick(attrs, [:api_key, "api_key"], Auth.api_key())) do nil -> {:ok, nil} key -> {:ok, key} end end defp fetch_api_key(attrs, %{} = _governed_authority) do case normalize_string(pick(attrs, [:api_key, "api_key"])) do nil -> {:ok, nil} key -> {:ok, key} end end defp fetch_base_url(attrs, nil) do case BaseURL.resolve(attrs) do url when is_binary(url) and url != "" -> {:ok, url} _ -> {:error, :invalid_base_url} end end defp fetch_base_url(attrs, %{} = _governed_authority) do case BaseURL.resolve_explicit(attrs) || BaseURL.default() do url when is_binary(url) and url != "" -> {:ok, url} _ -> {:error, :invalid_base_url} end end defp fetch_codex_path_override(attrs) do case pick(attrs, [:codex_path_override, "codex_path_override", :codex_path, "codex_path"]) do nil -> {:ok, nil} "" -> {:error, :invalid_codex_path} override -> {:ok, override} end end defp fetch_execution_surface(attrs) do attrs |> pick([:execution_surface, "execution_surface"]) |> normalize_execution_surface() end defp fetch_telemetry_prefix(attrs) do case pick(attrs, [:telemetry_prefix, "telemetry_prefix"], [:codex]) do prefix when is_list(prefix) -> if Enum.all?(prefix, &is_atom/1) do {:ok, prefix} else {:error, {:invalid_telemetry_prefix, prefix}} end other -> {:error, {:invalid_telemetry_prefix, other}} end end defp effective_command_execution_surface( %__MODULE__{execution_surface: %ExecutionSurface{} = execution_surface}, nil ), do: {:ok, execution_surface} defp effective_command_execution_surface(%__MODULE__{}, execution_surface_override), do: normalize_execution_surface(execution_surface_override) defp pick(attrs, keys, default \\ nil) defp pick(attrs, [key | rest], default) do case Map.get(attrs, key) do nil -> pick(attrs, rest, default) value -> value end end defp pick(_attrs, [], default), do: default defp implicit_default_model?(%__MODULE__{model_payload: payload}) when is_map(payload) do case Map.get(payload, :resolution_source, Map.get(payload, "resolution_source")) do value when value in [:default, "default"] -> true _ -> false end end defp implicit_default_model?(%__MODULE__{}), do: false defp normalize_model_input(attrs, nil, allow_unknown_model) do attrs |> apply_model_env_defaults() |> Map.put(:allow_unknown, allow_unknown_model) |> then(&ModelInput.normalize(:codex, &1, [])) end defp normalize_model_input(attrs, %{} = _governed_authority, allow_unknown_model) do attrs |> Map.put(:allow_unknown, allow_unknown_model) |> then(&ModelInput.normalize(:codex, &1, [])) end defp fetch_allow_unknown_model(attrs) do case pick(attrs, [:allow_unknown_model, "allow_unknown_model"]) do nil -> {:ok, true} value when is_boolean(value) -> {:ok, value} other -> {:error, {:invalid_allow_unknown_model, other}} end end defp warn_if_unregistered_model(%{extra: %{"unregistered" => true}} = payload) do Logger.warning( "Codex model #{inspect(Map.get(payload, :resolved_model))} is not in the bundled " <> "model registry; passing it through as-is. This is expected for a model newer " <> "than the bundled registry. Pass allow_unknown_model: false to require a " <> "registered model." ) :ok end defp warn_if_unregistered_model(_payload), do: :ok defp apply_model_env_defaults(attrs) when is_map(attrs) do if explicit_model_payload?(attrs) do attrs else attrs |> put_missing_attr( :env_model, Codex.Env.get("CODEX_MODEL") || Codex.Env.get("OPENAI_DEFAULT_MODEL") || Codex.Env.get("CODEX_MODEL_DEFAULT") ) |> put_missing_attr(:provider_backend, Codex.Env.get("CODEX_PROVIDER_BACKEND")) |> put_missing_attr(:oss_provider, Codex.Env.get("CODEX_OSS_PROVIDER")) |> put_missing_attr(:ollama_base_url, Codex.Env.get("CODEX_OLLAMA_BASE_URL")) end end defp explicit_model_payload?(attrs) when is_map(attrs) do case Map.get(attrs, :model_payload, Map.get(attrs, "model_payload")) do nil -> false _payload -> true end end defp execution_surface_attrs(attrs) when is_map(attrs) do [ surface_kind: Map.get(attrs, :surface_kind, Map.get(attrs, "surface_kind")), transport_options: Map.get(attrs, :transport_options, Map.get(attrs, "transport_options")), target_id: Map.get(attrs, :target_id, Map.get(attrs, "target_id")), lease_ref: Map.get(attrs, :lease_ref, Map.get(attrs, "lease_ref")), surface_ref: Map.get(attrs, :surface_ref, Map.get(attrs, "surface_ref")), boundary_class: Map.get(attrs, :boundary_class, Map.get(attrs, "boundary_class")), observability: Map.get(attrs, :observability, Map.get(attrs, "observability", %{})) ] end defp put_missing_attr(attrs, _key, nil), do: attrs defp put_missing_attr(attrs, _key, ""), do: attrs defp put_missing_attr(attrs, key, value) when is_map(attrs) and is_atom(key) do cond do Map.has_key?(attrs, key) -> attrs Map.has_key?(attrs, Atom.to_string(key)) -> attrs true -> Map.put(attrs, key, value) end end defp fetch_model(model_payload) when is_map(model_payload) do {:ok, Map.get(model_payload, :resolved_model, Map.get(model_payload, "resolved_model"))} end defp fetch_reasoning_effort(model_payload) when is_map(model_payload) do reasoning = Map.get(model_payload, :reasoning, Map.get(model_payload, "reasoning")) Models.normalize_reasoning_effort(reasoning) end defp fetch_model_personality(attrs) do attrs |> pick([:model_personality, "model_personality", :personality, "personality"]) |> normalize_personality() end defp fetch_reasoning_summary(attrs) do attrs |> pick([ :model_reasoning_summary, "model_reasoning_summary", :reasoning_summary, "reasoning_summary" ]) |> normalize_reasoning_summary() end defp fetch_model_verbosity(attrs) do attrs |> pick([:model_verbosity, "model_verbosity", :verbosity, "verbosity"]) |> normalize_model_verbosity() end defp fetch_model_context_window(attrs) do case pick(attrs, [ :model_context_window, "model_context_window", :context_window, "context_window" ]) do nil -> {:ok, nil} value when is_integer(value) and value > 0 -> {:ok, value} other -> {:error, {:invalid_model_context_window, other}} end end defp fetch_supports_reasoning_summaries(attrs) do case pick( attrs, [ :model_supports_reasoning_summaries, "model_supports_reasoning_summaries", :supports_reasoning_summaries, "supports_reasoning_summaries" ] ) do nil -> {:ok, nil} value when is_boolean(value) -> {:ok, value} other -> {:error, {:invalid_model_supports_reasoning_summaries, other}} end end defp fetch_model_auto_compact_token_limit(attrs) do case pick(attrs, [ :model_auto_compact_token_limit, "model_auto_compact_token_limit", :auto_compact_token_limit, "auto_compact_token_limit" ]) do nil -> {:ok, nil} value when is_integer(value) and value > 0 -> {:ok, value} other -> {:error, {:invalid_model_auto_compact_token_limit, other}} end end defp fetch_review_model(attrs) do case pick(attrs, [:review_model, "review_model"]) do nil -> {:ok, nil} "" -> {:ok, nil} value when is_binary(value) -> {:ok, value} other -> {:error, {:invalid_review_model, other}} end end defp fetch_history_persistence(attrs) do history = pick(attrs, [:history, "history"]) value = pick(attrs, [:history_persistence, "history_persistence"]) || if is_map(history) do Map.get(history, :persistence, Map.get(history, "persistence")) end normalize_history_persistence(value) end defp fetch_history_max_bytes(attrs) do history = pick(attrs, [:history, "history"]) value = pick(attrs, [:history_max_bytes, "history_max_bytes"]) || if is_map(history) do Map.get( history, :max_bytes, Map.get(history, "max_bytes", Map.get(history, "maxBytes")) ) end validate_history_max_bytes(value) end defp fetch_hide_agent_reasoning(attrs) do case pick(attrs, [:hide_agent_reasoning, "hide_agent_reasoning"]) do nil -> {:ok, false} value when is_boolean(value) -> {:ok, value} other -> {:error, {:invalid_hide_agent_reasoning, other}} end end defp fetch_tool_output_token_limit(attrs) do case pick(attrs, [ :tool_output_token_limit, "tool_output_token_limit", :tool_output_limit, "tool_output_limit" ]) do nil -> {:ok, nil} value when is_integer(value) and value > 0 -> {:ok, value} other -> {:error, {:invalid_tool_output_token_limit, other}} end end defp fetch_agent_max_threads(attrs) do case pick(attrs, [ :agent_max_threads, "agent_max_threads", :max_threads, "max_threads" ]) do nil -> {:ok, nil} value when is_integer(value) and value > 0 -> {:ok, value} other -> {:error, {:invalid_agent_max_threads, other}} end end defp fetch_config_overrides(attrs) do attrs |> pick([:config_overrides, "config_overrides", :config, "config"], []) |> Overrides.normalize_config_overrides() end defp normalize_string(nil), do: nil defp normalize_string(value) when is_binary(value) do value = String.trim(value) if value == "", do: nil, else: value end defp normalize_string(_), do: nil defp normalize_reasoning_summary(value), do: OptionNormalizers.normalize_reasoning_summary(value, :invalid_model_reasoning_summary) defp normalize_history_persistence(value), do: OptionNormalizers.normalize_history_persistence(value, :invalid_history_persistence) defp validate_history_max_bytes(nil), do: {:ok, nil} defp validate_history_max_bytes(value) when is_integer(value) and value >= 0, do: {:ok, value} defp validate_history_max_bytes(other), do: {:error, {:invalid_history_max_bytes, other}} defp normalize_model_verbosity(value), do: OptionNormalizers.normalize_model_verbosity(value, :invalid_model_verbosity) defp normalize_personality(nil), do: {:ok, nil} defp normalize_personality(value) when is_atom(value) do value |> Atom.to_string() |> normalize_personality() end defp normalize_personality(value) when is_binary(value) do case String.downcase(String.trim(value)) do "" -> {:ok, nil} "friendly" -> {:ok, :friendly} "pragmatic" -> {:ok, :pragmatic} "none" -> {:ok, :none} other -> {:error, {:invalid_model_personality, other}} end end defp normalize_personality(other), do: {:error, {:invalid_model_personality, other}} end