Packages

Graph-first runtime for building agent systems on the BEAM in Elixir

Current section

Files

Jump to
ex_ai lib ex_ai model secrets.ex
Raw

lib/ex_ai/model/secrets.ex

defmodule ExAI.Model.Secrets do
@moduledoc """
Secret resolution for model credentials and provider profiles.
"""
alias ExAI.Error
alias ExAI.Types
@type secret_ref :: Types.secret_ref()
@spec resolve(secret_ref() | term(), keyword()) :: {:ok, String.t()} | {:error, Error.t()}
def resolve(secret_ref, opts \\ []) do
with {:ok, value} <- resolve_builtin(secret_ref, opts),
{:ok, normalized} <- normalize_secret(value, secret_ref) do
{:ok, normalized}
else
{:error, %Error{} = error} ->
maybe_resolve_with_provider(secret_ref, opts, error)
end
end
@spec resolve_profile(atom() | String.t() | map(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
def resolve_profile(profile_or_name, opts \\ []) do
with {:ok, profile} <- fetch_profile(profile_or_name, opts),
{:ok, resolved} <- resolve_value(profile, opts) do
{:ok, resolved}
end
end
@spec resolve_builtin(term(), keyword()) :: {:ok, term()} | {:error, Error.t()}
defp resolve_builtin({:direct, value}, _opts), do: {:ok, value}
defp resolve_builtin({:runtime, key}, opts) do
runtime_secrets = Keyword.get(opts, :runtime_secrets, %{})
case fetch_map_key(runtime_secrets, key) do
{:ok, value} ->
{:ok, value}
:error ->
{:error, Error.new(:secret_error, "runtime secret not found", details: %{key: key})}
end
end
defp resolve_builtin({:env, variable}, _opts) when is_binary(variable) do
case System.get_env(variable) do
nil ->
{:error,
Error.new(:secret_error, "environment secret not found", details: %{variable: variable})}
value ->
{:ok, value}
end
end
defp resolve_builtin({:app, app, key}, _opts) when is_atom(app) do
case Application.get_env(app, key) do
nil ->
{:error,
Error.new(:secret_error, "application secret not found", details: %{app: app, key: key})}
value ->
{:ok, value}
end
end
defp resolve_builtin(other, _opts) do
{:error,
Error.new(:secret_error, "unsupported secret reference", details: %{ref: inspect(other)})}
end
@spec maybe_resolve_with_provider(term(), keyword(), Error.t()) ::
{:ok, String.t()} | {:error, Error.t()}
defp maybe_resolve_with_provider(secret_ref, opts, fallback_error) do
case Keyword.get(opts, :provider) do
nil ->
{:error, fallback_error}
provider when is_atom(provider) ->
call_provider(provider, secret_ref, opts)
other ->
{:error,
Error.new(:secret_error, "invalid secret provider", details: %{provider: inspect(other)})}
end
end
@spec call_provider(module(), term(), keyword()) :: {:ok, String.t()} | {:error, Error.t()}
defp call_provider(provider, secret_ref, opts) do
if function_exported?(provider, :fetch, 2) do
case provider.fetch(secret_ref, opts) do
{:ok, value} ->
normalize_secret(value, secret_ref)
{:error, %Error{} = error} ->
{:error, error}
{:error, reason} ->
{:error,
Error.new(:secret_error, "secret provider failed",
details: %{provider: inspect(provider), reason: inspect(reason)}
)}
other ->
{:error,
Error.new(:secret_error, "secret provider returned invalid value",
details: %{provider: inspect(provider), value: inspect(other)}
)}
end
else
{:error,
Error.new(:secret_error, "secret provider does not implement fetch/2",
details: %{provider: inspect(provider)}
)}
end
end
@spec normalize_secret(term(), term()) :: {:ok, String.t()} | {:error, Error.t()}
defp normalize_secret(value, _ref) when is_binary(value) and value != "", do: {:ok, value}
defp normalize_secret(value, _ref) when is_binary(value), do: {:ok, value}
defp normalize_secret(value, _ref) when is_atom(value) do
{:ok, Atom.to_string(value)}
end
defp normalize_secret(value, _ref) when is_number(value) do
{:ok, to_string(value)}
end
defp normalize_secret(nil, ref) do
{:error, Error.new(:secret_error, "resolved secret is nil", details: %{ref: inspect(ref)})}
end
defp normalize_secret(value, _ref) do
{:ok, inspect(value)}
end
@spec fetch_profile(atom() | String.t() | map(), keyword()) ::
{:ok, map()} | {:error, Error.t()}
defp fetch_profile(profile, _opts) when is_map(profile), do: {:ok, profile}
defp fetch_profile(profile_name, opts) when is_atom(profile_name) or is_binary(profile_name) do
profiles =
Keyword.get(opts, :profiles, Application.get_env(:ex_ai, :credential_profiles, %{}))
with {:ok, profiles_map} <- normalize_profiles(profiles),
{:ok, profile} <- fetch_profile_entry(profiles_map, profile_name) do
{:ok, profile}
end
end
defp fetch_profile(other, _opts) do
{:error,
Error.new(:secret_error, "invalid credential profile reference",
details: %{profile: inspect(other)}
)}
end
@spec normalize_profiles(term()) :: {:ok, map()} | {:error, Error.t()}
defp normalize_profiles(profiles) when is_map(profiles), do: {:ok, profiles}
defp normalize_profiles(profiles) when is_list(profiles), do: {:ok, Map.new(profiles)}
defp normalize_profiles(other) do
{:error,
Error.new(:secret_error, "credential profiles must be a map or keyword list",
details: %{profiles: inspect(other)}
)}
end
@spec fetch_profile_entry(map(), atom() | String.t()) :: {:ok, map()} | {:error, Error.t()}
defp fetch_profile_entry(profiles, profile_name) do
case fetch_map_key(profiles, profile_name) do
{:ok, profile} when is_map(profile) ->
{:ok, profile}
{:ok, other} ->
{:error,
Error.new(:secret_error, "credential profile must be a map",
details: %{profile: profile_name, value: inspect(other)}
)}
:error ->
{:error,
Error.new(:secret_error, "credential profile not found",
details: %{profile: profile_name}
)}
end
end
@spec resolve_value(term(), keyword()) :: {:ok, term()} | {:error, Error.t()}
defp resolve_value(value, opts) when is_map(value) do
Enum.reduce_while(value, {:ok, %{}}, fn {key, nested}, {:ok, acc} ->
case resolve_value(nested, opts) do
{:ok, resolved_nested} -> {:cont, {:ok, Map.put(acc, key, resolved_nested)}}
{:error, %Error{} = error} -> {:halt, {:error, error}}
end
end)
end
defp resolve_value(value, opts) when is_list(value) do
Enum.reduce_while(value, {:ok, []}, fn nested, {:ok, acc} ->
case resolve_value(nested, opts) do
{:ok, resolved_nested} -> {:cont, {:ok, acc ++ [resolved_nested]}}
{:error, %Error{} = error} -> {:halt, {:error, error}}
end
end)
end
defp resolve_value(value, opts) do
if secret_ref?(value) do
resolve(value, opts)
else
{:ok, value}
end
end
@spec secret_ref?(term()) :: boolean()
defp secret_ref?({:env, variable}) when is_binary(variable), do: true
defp secret_ref?({:app, app, key}) when is_atom(app) and (is_atom(key) or is_binary(key)),
do: true
defp secret_ref?({:runtime, key}) when is_atom(key) or is_binary(key), do: true
defp secret_ref?({:direct, _value}), do: true
defp secret_ref?(_), do: false
@spec fetch_map_key(map(), atom() | String.t()) :: {:ok, term()} | :error
defp fetch_map_key(map, key) do
cond do
Map.has_key?(map, key) ->
{:ok, Map.get(map, key)}
is_atom(key) and Map.has_key?(map, Atom.to_string(key)) ->
{:ok, Map.get(map, Atom.to_string(key))}
is_binary(key) and atom_key?(key) and has_existing_atom_key?(map, key) ->
{:ok, Map.get(map, String.to_existing_atom(key))}
true ->
:error
end
rescue
ArgumentError -> :error
end
@spec has_existing_atom_key?(map(), String.t()) :: boolean()
defp has_existing_atom_key?(map, key) do
Map.has_key?(map, String.to_existing_atom(key))
rescue
ArgumentError -> false
end
@spec atom_key?(String.t()) :: boolean()
defp atom_key?(key), do: String.match?(key, ~r/^[a-zA-Z_][a-zA-Z0-9_]*$/u)
end