Current section
Files
Jump to
Current section
Files
lib/asm/options.ex
defmodule ASM.Options do
@moduledoc """
Shared runtime options validation and normalization.
"""
alias ASM.{Error, Permission, ProviderFeatures}
alias ASM.Schema.ProviderOptions, as: ProviderOptionsSchema
alias CliSubprocessCore.ModelInput
alias CliSubprocessCore.ModelRegistry.Selection
@common_keys [
:provider,
:permission_mode,
:provider_permission_mode,
:cli_path,
:cwd,
:env,
:args,
:ollama,
:ollama_model,
:ollama_base_url,
:ollama_http,
:ollama_timeout_ms,
:model_payload,
:queue_limit,
:overflow_policy,
:subscriber_queue_warn,
:subscriber_queue_limit,
:approval_timeout_ms,
:transport_timeout_ms,
:transport_headless_timeout_ms,
:max_stdout_buffer_bytes,
:max_stderr_buffer_bytes,
:max_concurrent_runs,
:max_queued_runs,
:debug
]
@type t :: keyword()
@spec schema() :: keyword()
def schema do
[
provider: [type: :atom, required: true],
permission_mode: [type: {:or, [:atom, :string]}, default: :default],
provider_permission_mode: [type: {:or, [:atom, nil]}, default: nil],
cli_path: [type: {:or, [:string, nil]}, default: nil],
cwd: [type: {:or, [:string, nil]}, default: nil],
env: [type: {:custom, __MODULE__, :validate_passthrough_map, [:env]}, default: %{}],
args: [type: {:list, :string}, default: []],
ollama: [type: :boolean, default: false],
ollama_model: [type: {:or, [:string, nil]}, default: nil],
ollama_base_url: [type: {:or, [:string, nil]}, default: nil],
ollama_http: [type: {:or, [:boolean, nil]}, default: nil],
ollama_timeout_ms: [type: {:or, [:pos_integer, nil]}, default: nil],
model_payload: [
type: {:custom, __MODULE__, :validate_model_payload, [:model_payload]},
default: nil
],
queue_limit: [type: :pos_integer, default: app_default(:queue_limit, 1_000)],
overflow_policy: [
type: {:in, [:fail_run, :drop_oldest, :block]},
default: app_default(:overflow_policy, :fail_run)
],
subscriber_queue_warn: [
type: :non_neg_integer,
default: app_default(:subscriber_queue_warn, 100)
],
subscriber_queue_limit: [
type: :pos_integer,
default: app_default(:subscriber_queue_limit, 500)
],
approval_timeout_ms: [
type: :pos_integer,
default: app_default(:approval_timeout_ms, 120_000)
],
transport_timeout_ms: [type: :pos_integer, default: 60_000],
transport_headless_timeout_ms: [
type: {:or, [:pos_integer, {:in, [:infinity]}]},
default: app_default(:transport_headless_timeout_ms, 5_000)
],
max_stdout_buffer_bytes: [
type: :pos_integer,
default: app_default(:max_stdout_buffer_bytes, 1_048_576)
],
max_stderr_buffer_bytes: [
type: :pos_integer,
default: app_default(:max_stderr_buffer_bytes, 65_536)
],
max_concurrent_runs: [type: :pos_integer, default: 1],
max_queued_runs: [type: :non_neg_integer, default: 10],
debug: [type: :boolean, default: false]
]
end
@spec validate(keyword(), keyword()) :: {:ok, t()} | {:error, Error.t()}
def validate(opts, provider_schema \\ [])
when is_list(opts) and is_list(provider_schema) do
with {:ok, merged_schema} <- merge_provider_schema(provider_schema),
{:ok, validated} <- validate_schema(opts, merged_schema),
{:ok, validated} <- normalize_permission_modes(validated) do
with {:ok, normalized} <- normalize_common_features(validated),
{:ok, _validated} <- ProviderOptionsSchema.validate(normalized) do
{:ok, normalized}
else
{:error, {:invalid_provider_options, details}} ->
{:error, config_error(details.message, validation: details)}
{:error, %Error{} = error} ->
{:error, error}
end
end
end
@spec validate!(keyword(), keyword()) :: t()
def validate!(opts, provider_schema \\ []) do
case validate(opts, provider_schema) do
{:ok, validated} ->
validated
{:error, %Error{} = error} ->
raise ArgumentError, Exception.message(error)
end
end
@spec merge_provider_schema(keyword()) :: {:ok, keyword()} | {:error, Error.t()}
def merge_provider_schema(provider_schema) when is_list(provider_schema) do
collisions =
provider_schema
|> Keyword.keys()
|> Enum.filter(&(&1 in @common_keys))
if collisions == [] do
{:ok, schema() ++ provider_schema}
else
{:error,
config_error(
"Provider schema collides with reserved keys: #{Enum.map_join(collisions, ", ", &inspect/1)}",
collisions: collisions
)}
end
end
defp validate_schema(opts, merged_schema) do
case NimbleOptions.validate(opts, merged_schema) do
{:ok, validated} ->
{:ok, validated}
{:error, %NimbleOptions.ValidationError{} = error} ->
{:error, config_error(Exception.message(error), validation: error)}
end
end
defp normalize_permission_modes(validated) do
provider =
validated
|> Keyword.fetch!(:provider)
|> Permission.canonical_provider()
permission_mode = Keyword.get(validated, :permission_mode, :default)
case Permission.normalize(provider, permission_mode) do
{:ok, %{normalized: normalized, native: native}} ->
validated
|> Keyword.put(:provider, provider)
|> Keyword.put(:permission_mode, normalized)
|> Keyword.put(:provider_permission_mode, native)
|> then(&{:ok, &1})
{:error, %Error{} = error} ->
{:error, error}
end
end
defp app_default(key, default) do
Application.get_env(:agent_session_manager, key, default)
end
defp normalize_common_features(validated) do
validated
|> normalize_ollama_surface()
|> then(&{:ok, &1})
rescue
error in [ArgumentError] ->
{:error, config_error(Exception.message(error), normalized: validated, exception: error)}
end
@doc false
def validate_passthrough_map(value, key) when is_map(value) do
if Enum.all?(Map.keys(value), &(is_atom(&1) or is_binary(&1))) do
{:ok, value}
else
{:error, "expected #{inspect(key)} map keys to be atoms or strings"}
end
end
def validate_passthrough_map(nil, _key), do: {:ok, nil}
def validate_passthrough_map(value, key) do
{:error, "expected #{inspect(key)} to be a map, got: #{inspect(value)}"}
end
def validate_model_payload(nil, _key), do: {:ok, nil}
def validate_model_payload(value, _key) when is_map(value), do: {:ok, value}
def validate_model_payload(value, key) do
{:error, "expected #{inspect(key)} to be a map/struct, got: #{inspect(value)}"}
end
@spec finalize_provider_opts(atom(), keyword(), keyword()) ::
{:ok, keyword()} | {:error, Error.t()}
def finalize_provider_opts(provider, provider_opts, opts \\ [])
when is_atom(provider) and is_list(provider_opts) and is_list(opts) do
with :ok <- reject_legacy_transport_surface(provider, provider_opts),
{:ok, normalized} <- normalize_model_input(provider, provider_opts, opts) do
{:ok, normalized.attrs}
else
{:error, %Error{} = error} ->
{:error, error}
{:error, reason} ->
{:error,
config_error(
"model resolution failed for #{inspect(provider)}: #{inspect(reason)}",
provider: provider,
reason: reason
)}
end
end
@spec resolve_model_payload(atom(), keyword()) ::
{:ok, CliSubprocessCore.ModelRegistry.selection()} | {:error, Error.t()}
def resolve_model_payload(provider, provider_opts)
when is_atom(provider) and is_list(provider_opts) do
case finalize_provider_opts(provider, provider_opts) do
{:ok, finalized} ->
{:ok, normalize_model_payload(Keyword.fetch!(finalized, :model_payload))}
{:error, %Error{} = error} ->
{:error, error}
end
end
@spec attach_model_payload(keyword(), map()) :: keyword()
def attach_model_payload(provider_opts, model_payload)
when is_list(provider_opts) and is_map(model_payload) do
provider_opts
|> Keyword.put(:model_payload, model_payload)
|> Keyword.delete(:model)
|> Keyword.delete(:reasoning)
|> Keyword.delete(:reasoning_effort)
|> Keyword.delete(:provider_backend)
|> Keyword.delete(:model_provider)
|> Keyword.delete(:oss_provider)
|> Keyword.delete(:ollama_base_url)
|> Keyword.delete(:ollama_http)
|> Keyword.delete(:ollama_timeout_ms)
|> Keyword.delete(:ollama)
|> Keyword.delete(:ollama_model)
end
defp normalize_model_payload(%Selection{} = payload), do: payload
defp normalize_model_payload(payload) when is_map(payload), do: Selection.new(payload)
defp normalize_model_input(provider, provider_opts, opts)
when is_atom(provider) and is_list(provider_opts) and is_list(opts) do
strip_keys =
[:ollama, :ollama_model]
|> Kernel.++(Keyword.get(opts, :strip_keys, []))
|> Enum.uniq()
ModelInput.normalize(provider, provider_opts, strip_keys: strip_keys)
end
defp reject_legacy_transport_surface(provider, provider_opts) do
if Keyword.has_key?(provider_opts, :transport_module) do
{:error,
config_error(
"#{inspect(provider)} no longer accepts legacy transport-selector overrides; transport selection is internal to cli_subprocess_core",
provider: provider,
option: :transport_selector
)}
else
:ok
end
end
defp normalize_ollama_surface(validated) when is_list(validated) do
provider = Keyword.fetch!(validated, :provider)
feature_provider = canonical_feature_provider(provider)
if ollama_requested?(validated) do
ensure_ollama_supported!(feature_provider)
case feature_provider do
:claude ->
normalize_claude_ollama_surface(validated)
:codex ->
normalize_codex_ollama_surface(validated)
_other ->
raise ArgumentError,
"provider #{inspect(provider)} does not support the common Ollama surface"
end
else
validated
end
end
defp ollama_requested?(validated) when is_list(validated) do
Keyword.get(validated, :ollama, false) or
Keyword.get(validated, :ollama_model) not in [nil, ""] or
Keyword.get(validated, :ollama_base_url) not in [nil, ""] or
not is_nil(Keyword.get(validated, :ollama_http)) or
not is_nil(Keyword.get(validated, :ollama_timeout_ms))
end
defp ensure_ollama_supported!(provider) when is_atom(provider) do
if ProviderFeatures.supports_common_feature?(provider, :ollama) do
:ok
else
raise ArgumentError,
"provider #{inspect(provider)} does not support the common Ollama surface"
end
end
defp normalize_claude_ollama_surface(validated) do
backend = Keyword.get(validated, :provider_backend)
ollama_model = present_binary(Keyword.get(validated, :ollama_model))
requested_model = present_binary(Keyword.get(validated, :model))
external_model_overrides = Keyword.get(validated, :external_model_overrides, %{}) || %{}
if backend not in [nil, :ollama, "ollama"] do
raise ArgumentError,
"common Ollama surface conflicts with :provider_backend=#{inspect(backend)} for :claude"
end
overrides =
if is_binary(requested_model) and is_binary(ollama_model) and
requested_model != ollama_model do
merge_claude_ollama_override!(external_model_overrides, requested_model, ollama_model)
else
external_model_overrides
end
validated
|> Keyword.put(:ollama, true)
|> Keyword.put(:provider_backend, :ollama)
|> maybe_put(:anthropic_base_url, Keyword.get(validated, :ollama_base_url))
|> maybe_put(:anthropic_auth_token, Keyword.get(validated, :anthropic_auth_token) || "ollama")
|> maybe_put(:model, requested_model || ollama_model)
|> Keyword.put(:external_model_overrides, overrides)
end
defp merge_claude_ollama_override!(overrides, requested_model, ollama_model)
when is_map(overrides) and is_binary(requested_model) and is_binary(ollama_model) do
normalized_overrides =
Map.new(overrides, fn {key, value} -> {to_string(key), to_string(value)} end)
case Map.get(normalized_overrides, requested_model) do
nil ->
Map.put(normalized_overrides, requested_model, ollama_model)
^ollama_model ->
normalized_overrides
other ->
raise ArgumentError,
"common Ollama surface conflicts with external_model_overrides[#{inspect(requested_model)}]=#{inspect(other)}"
end
end
defp normalize_codex_ollama_surface(validated) do
backend = Keyword.get(validated, :provider_backend)
oss_provider = present_binary(Keyword.get(validated, :oss_provider))
ollama_model = present_binary(Keyword.get(validated, :ollama_model))
requested_model = present_binary(Keyword.get(validated, :model))
if backend not in [nil, :oss, "oss"] do
raise ArgumentError,
"common Ollama surface conflicts with :provider_backend=#{inspect(backend)} for :codex"
end
if oss_provider not in [nil, "ollama"] do
raise ArgumentError,
"common Ollama surface conflicts with :oss_provider=#{inspect(oss_provider)} for :codex"
end
if is_binary(requested_model) and is_binary(ollama_model) and requested_model != ollama_model do
raise ArgumentError,
"common Ollama surface :model=#{inspect(requested_model)} conflicts with :ollama_model=#{inspect(ollama_model)} for :codex"
end
validated
|> Keyword.put(:ollama, true)
|> Keyword.put(:provider_backend, :oss)
|> Keyword.put(:oss_provider, "ollama")
|> maybe_put(:model, requested_model || ollama_model)
end
defp present_binary(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp present_binary(_value), do: nil
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, _key, ""), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp canonical_feature_provider(:codex_exec), do: :codex
defp canonical_feature_provider(provider), do: provider
defp config_error(message, details) do
Error.new(:config_invalid, :config, message, cause: details)
end
end