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: "")}, {"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