Packages
A Fair Source multi-agent runtime with deterministic agent scoring and replayable run history.
Current section
Files
Jump to
Current section
Files
lib/syntropy/runtime_config.ex
defmodule Syntropy.RuntimeConfig do
@moduledoc """
Read and apply runtime provider configuration.
Provider settings live in the Application environment, seeded from env vars
at boot (`config/runtime.exs`). `apply_provider/1` validates a candidate
configuration with `Syntropy.LLMClient.Config` and only then writes it with
`Application.put_env/3`, so an invalid update never disturbs a working
provider.
Applied changes take effect immediately for subsequent LLM calls. When
persistence is enabled they are also written through to `runtime_settings`
and rehydrated on boot (`hydrate/0`), so an applied configuration survives
restarts. Without persistence they are memory-scoped and reset on restart;
reports include an env-var block (`env_export`) that operators can copy to
make a configuration permanent either way.
"""
require Logger
alias Syntropy.{ClusterInfo, EventRecorder, LLMClient, Persistence, PersistenceHealth, Usage}
alias Syntropy.LLMClient.Config
@provider_fields ~w(provider profile model base_url api_key timeout_ms retry_count max_tokens max_concurrency temperature top_p repeat_penalty disable_thinking)
@clearable_fields ~w(model base_url api_key max_tokens max_concurrency temperature top_p repeat_penalty)
@probe_timeout_ms 5_000
@provider_setting_key "provider_overrides"
@type provider_report :: %{
provider: String.t(),
profile: String.t(),
model: String.t() | nil,
base_url: String.t() | nil,
api_key_configured: boolean(),
timeout_ms: pos_integer(),
retry_count: non_neg_integer(),
max_tokens: pos_integer() | nil,
max_concurrency: pos_integer() | nil,
temperature: float() | nil,
top_p: float() | nil,
repeat_penalty: float() | nil,
disable_thinking: boolean(),
valid: boolean(),
validation_errors: [String.t()],
applied_scope: String.t(),
env_export: String.t()
}
@doc """
Full runtime configuration report: effective provider config plus
persistence, cluster, and source-tool posture. Never includes secrets.
"""
@spec report() :: map()
def report do
%{
provider: provider_report(),
persistence: persistence_posture(),
cluster: cluster_posture(),
source_tool: source_tool_posture(),
limits: limits_report()
}
end
@doc """
Fair-use limits posture: the rolling token budget and its enforcement
state.
"""
@spec limits_report() :: map()
def limits_report do
%{usage_budget: Usage.budget_report()}
end
@doc """
Effective provider configuration with the API key redacted, plus the env
block that reproduces it permanently.
"""
@spec provider_report() :: provider_report()
def provider_report do
LLMClient.config() |> describe()
end
@doc """
Validates a candidate provider configuration and applies it to the running
node when valid.
Accepts a map with string keys (JSON body). Unknown keys are ignored. Keys
that are present with `nil` or `""` clear the stored value; absent keys keep
the current value. Records a redacted `runtime_config_updated` event on
success.
"""
@spec apply_provider(map()) :: {:ok, provider_report()} | {:error, [String.t()]}
def apply_provider(params) when is_map(params) do
current = Application.get_env(:syntropy, Syntropy.LLMClient, [])
candidate = merge_params(current, params)
config = Config.load(candidate)
case Config.validate(config) do
:ok ->
Application.put_env(:syntropy, Syntropy.LLMClient, candidate)
persist_provider_overrides(candidate)
record_update(params, config)
{:ok, describe(config)}
{:error, errors} ->
{:error, errors}
end
end
@doc """
Rehydrates persisted provider overrides into the Application environment.
Runs at boot (before the endpoint and scheduler start) so an applied
configuration survives restarts. Persisted overrides layer on top of the
env-seeded configuration and are ignored when they no longer validate.
"""
@spec hydrate() :: :ok
def hydrate do
case Persistence.get_setting(@provider_setting_key) do
%{} = overrides ->
current = Application.get_env(:syntropy, Syntropy.LLMClient, [])
candidate = merge_params(current, overrides)
config = Config.load(candidate)
case Config.validate(config) do
:ok ->
Application.put_env(:syntropy, Syntropy.LLMClient, candidate)
{:error, errors} ->
Logger.warning("Ignoring persisted provider overrides: #{Enum.join(errors, " / ")}")
end
_missing ->
:ok
end
:ok
end
@doc """
Probes the configured provider endpoint without mutating anything.
Accepts the same params as `apply_provider/1`, layered over the current
configuration, so a candidate can be tested before it is applied. Mock is
always reachable; Ollama probes `GET /tags`; OpenAI-compatible providers
probe `GET /models` with the bearer key.
"""
@spec test_provider(map()) :: %{
status: String.t(),
provider: String.t(),
model: String.t() | nil,
base_url: String.t() | nil,
details: map()
}
def test_provider(params) when is_map(params) do
current = Application.get_env(:syntropy, Syntropy.LLMClient, [])
config = current |> merge_params(params) |> Config.load()
base = %{
provider: Config.provider_name(config),
model: config.model,
base_url: config.base_url
}
case Config.validate(config) do
:ok ->
Map.merge(base, probe(config))
{:error, errors} ->
Map.merge(base, %{status: "invalid", details: %{reasons: errors}})
end
end
@doc """
Env-var block that reproduces the given configuration across restarts.
The API key is always redacted.
"""
@spec env_export(Config.t()) :: String.t()
def env_export(%Config{} = config) do
[
{"SYNTROPY_LLM_PROVIDER", Config.provider_name(config)},
{"SYNTROPY_LLM_PROFILE", config.profile},
{"SYNTROPY_LLM_MODEL", config.model},
{"SYNTROPY_LLM_BASE_URL", config.base_url},
{"SYNTROPY_LLM_API_KEY", if(config.api_key, do: "<your-api-key>")},
{"SYNTROPY_LLM_TIMEOUT_MS", config.timeout_ms},
{"SYNTROPY_LLM_RETRY_COUNT", config.retry_count},
{"SYNTROPY_LLM_MAX_TOKENS", config.max_tokens},
{"SYNTROPY_LLM_MAX_CONCURRENCY", config.max_concurrency},
{"SYNTROPY_LLM_TEMPERATURE", config.temperature},
{"SYNTROPY_LLM_TOP_P", config.top_p},
{"SYNTROPY_LLM_REPEAT_PENALTY", config.repeat_penalty},
{"SYNTROPY_LLM_DISABLE_THINKING", if(config.disable_thinking, do: "true")}
]
|> Enum.reject(fn {_name, value} -> is_nil(value) end)
|> Enum.map_join("\n", fn {name, value} -> "#{name}=#{value}" end)
end
defp persistence_posture do
if Persistence.enabled?() do
%{enabled: true, status: PersistenceHealth.status()}
else
%{enabled: false, status: "disabled"}
end
end
defp cluster_posture do
%{
enabled: ClusterInfo.enabled?(),
node_id: ClusterInfo.node_id(),
node_name: ClusterInfo.node_name()
}
end
defp source_tool_posture do
inquiry_mode =
:syntropy
|> Application.get_env(Syntropy.SourceReview, [])
|> Keyword.get(:inquiry_mode, "deterministic")
%{
enabled: Application.get_env(:syntropy, :source_tool_enabled, false),
inquiry_mode: inquiry_mode
}
end
defp describe(%Config{} = config) do
{valid, errors} =
case Config.validate(config) do
:ok -> {true, []}
{:error, errors} -> {false, errors}
end
%{
provider: Config.provider_name(config),
profile: config.profile,
model: config.model,
base_url: config.base_url,
api_key_configured: is_binary(config.api_key),
timeout_ms: config.timeout_ms,
retry_count: config.retry_count,
max_tokens: config.max_tokens,
max_concurrency: config.max_concurrency,
temperature: config.temperature,
top_p: config.top_p,
repeat_penalty: config.repeat_penalty,
disable_thinking: config.disable_thinking,
valid: valid,
validation_errors: errors,
applied_scope: applied_scope(),
env_export: env_export(config)
}
end
defp applied_scope do
if Persistence.enabled?(), do: "runtime_persisted", else: "runtime_memory"
end
defp persist_provider_overrides(candidate) do
overrides =
candidate
|> Enum.filter(fn {key, _value} -> Atom.to_string(key) in @provider_fields end)
|> Map.new(fn {key, value} -> {Atom.to_string(key), value} end)
@provider_setting_key
|> Persistence.put_setting(overrides)
|> Persistence.tolerate_write("runtime_setting_upsert")
end
defp merge_params(current, params) do
Enum.reduce(@provider_fields, current, fn field, acc ->
case Map.fetch(params, field) do
{:ok, value} -> put_field(acc, field, value)
:error -> acc
end
end)
end
defp put_field(config, field, value) do
key = String.to_existing_atom(field)
cond do
clearable?(field, value) -> Keyword.put(config, key, nil)
field == "disable_thinking" -> Keyword.put(config, key, value == true or value == "true")
true -> Keyword.put(config, key, value)
end
end
defp clearable?(field, value) do
field in @clearable_fields and (is_nil(value) or value == "")
end
defp record_update(params, %Config{} = config) do
EventRecorder.record("runtime_config_updated", %{
section: "provider",
provider: Config.provider_name(config),
profile: config.profile,
model: config.model,
base_url: config.base_url,
api_key_configured: is_binary(config.api_key),
changed_fields:
params |> Map.keys() |> Enum.filter(&(&1 in @provider_fields)) |> Enum.sort()
})
end
defp probe(%Config{provider: :mock}) do
%{status: "ok", details: %{note: "Mock provider is deterministic and always reachable."}}
end
defp probe(%Config{provider: :ollama} = config) do
http_probe("#{config.base_url}/tags", [])
end
defp probe(%Config{provider: :openai} = config) do
http_probe("#{config.base_url}/models", [{"authorization", "Bearer #{config.api_key}"}])
end
defp probe(%Config{}) do
%{status: "invalid", details: %{reasons: ["Unsupported provider."]}}
end
defp http_probe(url, headers) do
case Req.get(url: url, headers: headers, receive_timeout: @probe_timeout_ms, retry: false) do
{:ok, %{status: status}} when status in 200..299 ->
%{status: "ok", details: %{http_status: status}}
{:ok, %{status: status}} ->
%{status: "unreachable", details: %{http_status: status}}
{:error, reason} ->
%{status: "unreachable", details: %{reason: inspect(reason)}}
end
end
end