Packages

A Fair Source multi-agent runtime with deterministic agent scoring and replayable run history.

Retired package: Deprecated - superseded — operator console moved to the Syntropy app

Current section

Files

Jump to
syntropy lib syntropy llm_client config.ex
Raw

lib/syntropy/llm_client/config.ex

defmodule Syntropy.LLMClient.Config do
@moduledoc """
Runtime provider settings for Syntropy completions.
"""
@enforce_keys [:provider, :timeout_ms, :retry_count]
defstruct provider: :mock,
profile: "custom",
model: nil,
base_url: nil,
api_key: nil,
timeout_ms: 30_000,
retry_count: 1,
max_tokens: nil,
max_concurrency: nil,
disable_thinking: false,
temperature: nil,
top_p: nil,
repeat_penalty: nil,
assistant_prefill: nil,
provider_module: nil
@type provider :: :mock | :openai | :ollama | :unsupported
@type t :: %__MODULE__{
provider: provider(),
profile: String.t(),
model: String.t() | nil,
base_url: String.t() | nil,
api_key: String.t() | nil,
timeout_ms: pos_integer(),
retry_count: non_neg_integer(),
max_tokens: pos_integer() | nil,
max_concurrency: pos_integer() | nil,
disable_thinking: boolean(),
temperature: float() | nil,
top_p: float() | nil,
repeat_penalty: float() | nil,
assistant_prefill: String.t() | nil,
provider_module: module() | nil
}
@spec load() :: t()
def load do
load(Application.get_env(:syntropy, Syntropy.LLMClient, []))
end
@doc """
Builds a config struct from an explicit keyword list instead of the
Application environment. Used to validate candidate configurations before
they are applied.
"""
@spec load(keyword()) :: t()
def load(config) when is_list(config) do
provider =
config
|> Keyword.get(:provider, :mock)
|> normalize_provider()
profile =
config
|> Keyword.get(:profile)
|> normalize_profile()
profile_defaults = profile_defaults(profile)
%__MODULE__{
provider: provider,
profile: profile,
model:
config |> Keyword.get(:model) |> normalize_string() |> fallback(default_model(provider)),
base_url:
config
|> Keyword.get(:base_url)
|> normalize_string()
|> fallback(default_base_url(provider)),
api_key: normalize_string(Keyword.get(config, :api_key)),
timeout_ms:
normalize_positive_integer(
config_value(config, profile_defaults, :timeout_ms, 30_000),
30_000
),
retry_count:
normalize_non_neg_integer(config_value(config, profile_defaults, :retry_count, 1), 1),
max_tokens:
normalize_optional_positive_integer(
config_value(config, profile_defaults, :max_tokens, nil)
),
max_concurrency:
normalize_optional_positive_integer(
config_value(config, profile_defaults, :max_concurrency, nil)
),
disable_thinking: Keyword.get(config, :disable_thinking, false) == true,
temperature:
normalize_optional_float(config_value(config, profile_defaults, :temperature, nil),
min: 0.0,
max: 2.0
),
top_p:
normalize_optional_float(config_value(config, profile_defaults, :top_p, nil),
min: 0.0,
max: 1.0
),
repeat_penalty:
normalize_optional_float(config_value(config, profile_defaults, :repeat_penalty, nil),
min: 0.0,
max: 3.0
),
assistant_prefill: normalize_string(Keyword.get(config, :assistant_prefill)),
provider_module: Keyword.get(config, :provider_module)
}
end
@spec validate(t()) :: :ok | {:error, [String.t()]}
def validate(%__MODULE__{provider: :mock}) do
:ok
end
def validate(%__MODULE__{provider: :openai} = config) do
errors =
[]
|> require_present(config.model, "SYNTROPY_LLM_MODEL must be set for the OpenAI provider.")
|> require_present(
config.base_url,
"SYNTROPY_LLM_BASE_URL must be set for the OpenAI provider."
)
|> require_present(
config.api_key,
"SYNTROPY_LLM_API_KEY must be set for the OpenAI provider."
)
validate_errors(errors)
end
def validate(%__MODULE__{provider: :ollama} = config) do
errors =
[]
|> require_present(config.model, "SYNTROPY_LLM_MODEL must be set for the Ollama provider.")
|> require_present(
config.base_url,
"SYNTROPY_LLM_BASE_URL must be set for the Ollama provider."
)
validate_errors(errors)
end
def validate(%__MODULE__{}) do
{:error, ["SYNTROPY_LLM_PROVIDER must be one of mock, openai, or ollama."]}
end
@spec call_timeout(t()) :: pos_integer()
def call_timeout(%__MODULE__{timeout_ms: timeout_ms}) do
timeout_ms + 5_000
end
@spec provider_name(t()) :: String.t()
def provider_name(%__MODULE__{provider: provider}) do
Atom.to_string(provider)
end
defp validate_errors([]), do: :ok
defp validate_errors(errors), do: {:error, Enum.reverse(errors)}
defp require_present(errors, value, _message) when is_binary(value) and value != "", do: errors
defp require_present(errors, _value, message), do: [message | errors]
defp normalize_provider(:mock), do: :mock
defp normalize_provider(:openai), do: :openai
defp normalize_provider(:ollama), do: :ollama
defp normalize_provider(value) when is_binary(value) do
case value |> String.trim() |> String.downcase() do
"mock" -> :mock
"openai" -> :openai
"ollama" -> :ollama
_other -> :unsupported
end
end
defp normalize_provider(_provider), do: :unsupported
defp normalize_profile(nil), do: "custom"
defp normalize_profile(value) do
case value |> to_string() |> String.trim() |> String.downcase() do
"" -> "custom"
"local_conservative" -> "local_conservative"
"local_parallel" -> "local_parallel"
"hosted_reasoning" -> "hosted_reasoning"
"custom" -> "custom"
_other -> "custom"
end
end
defp profile_defaults("local_conservative") do
%{
timeout_ms: 180_000,
retry_count: 0,
max_tokens: 700,
max_concurrency: 1,
temperature: 0.1,
top_p: 0.9
}
end
defp profile_defaults("local_parallel") do
%{
timeout_ms: 240_000,
retry_count: 0,
max_tokens: 700,
max_concurrency: 2,
temperature: 0.1,
top_p: 0.9
}
end
defp profile_defaults("hosted_reasoning") do
%{
timeout_ms: 180_000,
retry_count: 1,
max_tokens: 1200,
max_concurrency: 3,
temperature: 0.1,
top_p: 0.9
}
end
defp profile_defaults(_profile), do: %{}
defp config_value(config, profile_defaults, key, fallback) do
config
|> Keyword.get(key)
|> fallback(Map.get(profile_defaults, key))
|> fallback(fallback)
end
defp normalize_string(nil), do: nil
defp normalize_string(value) do
case value |> to_string() |> String.trim() do
"" -> nil
normalized -> normalized
end
end
defp normalize_positive_integer(value, _fallback) when is_integer(value) and value > 0,
do: value
defp normalize_positive_integer(value, fallback) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} when parsed > 0 -> parsed
_other -> fallback
end
end
defp normalize_positive_integer(_value, fallback), do: fallback
defp normalize_optional_positive_integer(nil), do: nil
defp normalize_optional_positive_integer(value) do
case normalize_positive_integer(value, nil) do
parsed when is_integer(parsed) -> parsed
_other -> nil
end
end
defp normalize_optional_float(nil, _opts), do: nil
defp normalize_optional_float(value, opts) do
parsed =
cond do
is_float(value) -> value
is_integer(value) -> value / 1
is_binary(value) -> parse_float(value)
true -> nil
end
min = Keyword.fetch!(opts, :min)
max = Keyword.fetch!(opts, :max)
if is_float(parsed) and parsed >= min and parsed <= max, do: parsed, else: nil
end
defp parse_float(value) do
case Float.parse(String.trim(value)) do
{parsed, ""} -> parsed
_other -> nil
end
end
defp normalize_non_neg_integer(value, _fallback) when is_integer(value) and value >= 0,
do: value
defp normalize_non_neg_integer(value, fallback) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} when parsed >= 0 -> parsed
_other -> fallback
end
end
defp normalize_non_neg_integer(_value, fallback), do: fallback
defp fallback(nil, default), do: default
defp fallback(value, _default), do: value
defp default_model(:mock), do: "syntropy-mock"
defp default_model(:ollama), do: "llama3.1:8b"
defp default_model(_provider), do: nil
defp default_base_url(:openai), do: "https://api.openai.com/v1"
defp default_base_url(:ollama), do: "http://127.0.0.1:11434/api"
defp default_base_url(_provider), do: nil
end