defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
@moduledoc """
LiveView for creating and editing AI endpoints.
An endpoint combines provider credentials, model selection, and generation
parameters into a single configuration.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Modules.AI
alias PhoenixKit.Modules.AI.AIModel
alias PhoenixKit.Modules.AI.Endpoint
alias PhoenixKit.Modules.AI.OpenRouterClient
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
# ===========================================
# FUNCTION COMPONENTS
# ===========================================
attr :key, :string, required: true
attr :definition, :map, required: true
attr :form, :map, required: true
attr :endpoint, :any, default: nil
attr :selected_model, :map, default: nil
attr :size, :string, default: "md"
def param_input(assigns) do
field = assigns.definition.field
field_str = Atom.to_string(field)
current_value =
assigns.form.params[field_str] ||
(assigns.endpoint && Map.get(assigns.endpoint, field)) ||
assigns.definition[:default] ||
""
# For string_list type, convert array to newline-separated string
current_value =
case {assigns.definition.type, current_value} do
{:string_list, list} when is_list(list) -> Enum.join(list, "\n")
_ -> current_value
end
input_class =
case assigns.size do
"sm" -> "input input-bordered input-sm"
_ -> "input input-bordered"
end
textarea_class =
case assigns.size do
"sm" -> "textarea textarea-bordered textarea-sm"
_ -> "textarea textarea-bordered"
end
assigns =
assigns
|> assign(:field, field)
|> assign(:field_str, field_str)
|> assign(:current_value, current_value)
|> assign(:input_class, input_class)
|> assign(:textarea_class, textarea_class)
~H"""
<%= case @definition.type do %>
<% :float -> %>
<% :integer -> %>
<% :string_list -> %>
<% _ -> %>
<% end %>
"""
end
defp get_max_for_field("max_tokens", _definition, selected_model) do
selected_model && selected_model.max_completion_tokens
end
defp get_max_for_field(_field, definition, _selected_model) do
definition[:max]
end
defp get_placeholder_for_field("max_tokens", _definition, selected_model) do
if selected_model && selected_model.max_completion_tokens do
"Max: #{selected_model.max_completion_tokens}"
else
"Model default"
end
end
defp get_placeholder_for_field(_field, definition, _selected_model) do
definition[:placeholder]
end
# ===========================================
# LIFECYCLE
# ===========================================
@impl true
def mount(params, _session, socket) do
if AI.enabled?() do
project_title = Settings.get_project_title()
socket =
socket
|> assign(:project_title, project_title)
|> assign(:current_path, Routes.path("/admin/ai"))
|> assign(:validating_api_key, false)
|> assign(:api_key_valid, nil)
|> assign(:api_key_error, nil)
|> assign(:models, [])
|> assign(:models_grouped, [])
|> assign(:models_loading, false)
|> assign(:models_error, nil)
|> assign(:selected_model, nil)
|> assign(:selected_provider, nil)
|> assign(:provider_models, [])
|> load_endpoint(params["id"])
{:ok, socket}
else
{:ok,
socket
|> put_flash(:error, "AI module is not enabled")
|> push_navigate(to: Routes.path("/admin/modules"))}
end
end
defp load_endpoint(socket, nil) do
changeset = AI.change_endpoint(%Endpoint{})
socket
|> assign(:page_title, "New AI Endpoint")
|> assign(:endpoint, nil)
|> assign(:form, to_form(changeset))
end
defp load_endpoint(socket, id) do
case AI.get_endpoint(id) do
nil ->
socket
|> put_flash(:error, "Endpoint not found")
|> push_navigate(to: Routes.ai_path())
endpoint ->
changeset = AI.change_endpoint(endpoint)
socket =
socket
|> assign(:page_title, "Edit AI Endpoint")
|> assign(:endpoint, endpoint)
|> assign(:form, to_form(changeset))
# Load models if endpoint has API key
if endpoint.api_key && String.length(endpoint.api_key) > 10 do
send(self(), {:fetch_models, endpoint.api_key})
assign(socket, :models_loading, true)
else
socket
end
end
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@impl true
def handle_event("validate", %{"endpoint" => params}, socket) do
changeset =
(socket.assigns.endpoint || %Endpoint{})
|> AI.change_endpoint(params)
|> Map.put(:action, :validate)
# Update selected model when model changes
selected_model =
case params["model"] do
nil -> socket.assigns.selected_model
"" -> nil
model_id -> find_model(socket.assigns.models, model_id)
end
socket =
socket
|> assign(:form, to_form(changeset))
|> assign(:selected_model, selected_model)
{:noreply, socket}
end
@impl true
def handle_event("select_provider", %{"provider" => ""}, socket) do
# Reset provider selection
socket =
socket
|> assign(:selected_provider, nil)
|> assign(:provider_models, [])
{:noreply, socket}
end
@impl true
def handle_event("select_provider", %{"provider" => provider}, socket) do
# Find models for this provider
provider_models =
case Enum.find(socket.assigns.models_grouped, fn {p, _} -> p == provider end) do
{_, models} -> models
nil -> []
end
socket =
socket
|> assign(:selected_provider, provider)
|> assign(:provider_models, provider_models)
{:noreply, socket}
end
@impl true
def handle_event("select_model", %{"_target" => ["model"], "model" => ""}, socket) do
# Ignore when reset to placeholder
{:noreply, socket}
end
@impl true
def handle_event("select_model", %{"model" => model_id}, socket) when model_id != "" do
# Find the model details
selected_model = find_model(socket.assigns.models, model_id)
# Update the form with new model
current_params = socket.assigns.form.params || %{}
new_params = Map.put(current_params, "model", model_id)
changeset =
(socket.assigns.endpoint || %Endpoint{})
|> AI.change_endpoint(new_params)
|> Map.put(:action, :validate)
socket =
socket
|> assign(:form, to_form(changeset))
|> assign(:selected_model, selected_model)
{:noreply, socket}
end
@impl true
def handle_event("select_model", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("clear_model", _params, socket) do
# Clear the model selection
current_params = socket.assigns.form.params || %{}
new_params = Map.put(current_params, "model", "")
changeset =
(socket.assigns.endpoint || %Endpoint{})
|> AI.change_endpoint(new_params)
|> Map.put(:action, :validate)
socket =
socket
|> assign(:form, to_form(changeset))
|> assign(:selected_model, nil)
{:noreply, socket}
end
@impl true
def handle_event("set_manual_model", %{"model" => model_id}, socket) when model_id != "" do
# Set model from manual input (when models list not loaded)
current_params = socket.assigns.form.params || %{}
new_params = Map.put(current_params, "model", model_id)
changeset =
(socket.assigns.endpoint || %Endpoint{})
|> AI.change_endpoint(new_params)
|> Map.put(:action, :validate)
socket =
socket
|> assign(:form, to_form(changeset))
|> assign(:selected_model, nil)
{:noreply, socket}
end
@impl true
def handle_event("set_manual_model", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("toggle_reasoning", _params, socket) do
# Toggle the reasoning_enabled value in form params
current_value =
socket.assigns.form.params["reasoning_enabled"] == "true" ||
socket.assigns.form.params["reasoning_enabled"] == true ||
(socket.assigns.endpoint && socket.assigns.endpoint.reasoning_enabled == true)
new_value = if current_value, do: "false", else: "true"
updated_params = Map.put(socket.assigns.form.params, "reasoning_enabled", new_value)
form = %{socket.assigns.form | params: updated_params}
{:noreply, assign(socket, :form, form)}
end
@impl true
def handle_event("validate_api_key", _params, socket) do
api_key =
socket.assigns.form.params["api_key"] ||
(socket.assigns.endpoint && socket.assigns.endpoint.api_key) ||
""
if String.length(api_key) > 10 do
socket =
socket
|> assign(:validating_api_key, true)
|> assign(:models_loading, true)
send(self(), {:do_validate_api_key, api_key})
{:noreply, socket}
else
{:noreply, assign(socket, api_key_error: "Please enter an API key first")}
end
end
@impl true
def handle_event("save", %{"endpoint" => params}, socket) do
# Merge provider_settings from nested params
provider_settings = %{
"http_referer" => get_in(params, ["provider_settings", "http_referer"]) || "",
"x_title" => get_in(params, ["provider_settings", "x_title"]) || ""
}
params = Map.put(params, "provider_settings", provider_settings)
# Parse numeric fields and string lists
params =
params
|> parse_float("temperature")
|> parse_integer("max_tokens")
|> parse_float("top_p")
|> parse_integer("top_k")
|> parse_float("frequency_penalty")
|> parse_float("presence_penalty")
|> parse_float("repetition_penalty")
|> parse_integer("seed")
|> parse_integer("dimensions")
|> parse_string_list("stop")
save_endpoint(socket, params)
end
defp parse_float(params, key) do
case params[key] do
nil ->
params
"" ->
Map.put(params, key, nil)
val when is_binary(val) ->
case Float.parse(val) do
{num, _} -> Map.put(params, key, num)
:error -> params
end
_ ->
params
end
end
defp parse_integer(params, key) do
case params[key] do
nil ->
params
"" ->
Map.put(params, key, nil)
val when is_binary(val) ->
case Integer.parse(val) do
{num, _} -> Map.put(params, key, num)
:error -> params
end
_ ->
params
end
end
defp parse_string_list(params, key) do
case params[key] do
nil ->
params
"" ->
Map.put(params, key, nil)
val when is_binary(val) ->
# Split by newlines and filter empty strings
list =
val
|> String.split(~r/[\r\n]+/)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
if list == [] do
Map.put(params, key, nil)
else
Map.put(params, key, list)
end
_ ->
params
end
end
defp save_endpoint(socket, params) do
result =
if socket.assigns.endpoint do
AI.update_endpoint(socket.assigns.endpoint, params)
else
AI.create_endpoint(params)
end
case result do
{:ok, _endpoint} ->
action = if socket.assigns.endpoint, do: "updated", else: "created"
{:noreply,
socket
|> put_flash(:info, "Endpoint #{action} successfully")
|> push_navigate(to: Routes.ai_path())}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
rescue
e ->
require Logger
Logger.error("Endpoint save failed: #{Exception.message(e)}")
{:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))}
end
@impl true
def handle_info({:do_validate_api_key, api_key}, socket) do
case OpenRouterClient.validate_api_key(api_key) do
{:ok, _data} ->
# Also fetch models on successful validation
send(self(), {:fetch_models, api_key})
socket =
socket
|> assign(:validating_api_key, false)
|> assign(:api_key_valid, true)
|> assign(:api_key_error, nil)
{:noreply, socket}
{:error, reason} ->
socket =
socket
|> assign(:validating_api_key, false)
|> assign(:api_key_valid, false)
|> assign(:api_key_error, reason)
|> assign(:models_loading, false)
{:noreply, socket}
end
end
@impl true
def handle_info({:fetch_models, api_key}, socket) do
case OpenRouterClient.fetch_models_grouped(api_key, type: :all) do
{:ok, grouped} ->
# Flatten for easy lookup
models =
grouped
|> Enum.flat_map(fn {_provider, models} -> models end)
# Set selected model if editing existing endpoint
selected_model =
case socket.assigns.endpoint do
%{model: model_id} when is_binary(model_id) and model_id != "" ->
find_model(models, model_id)
_ ->
nil
end
socket =
socket
|> assign(:models_loading, false)
|> assign(:models, models)
|> assign(:models_grouped, grouped)
|> assign(:models_error, nil)
|> assign(:selected_model, selected_model)
{:noreply, socket}
{:error, reason} ->
socket =
socket
|> assign(:models_loading, false)
|> assign(:models_error, reason)
{:noreply, socket}
end
end
# Private helpers
defp find_model(models, model_id) do
Enum.find(models, fn m -> m.id == model_id end)
end
@doc """
Parameter definitions with type, constraints, and UI metadata.
Only parameters we support in the UI are defined here.
"""
def parameter_definitions do
%{
# Basic parameters
"temperature" => %{
type: :float,
label: "Temperature",
min: 0,
max: 2,
step: 0.1,
default: 0.7,
field: :temperature,
group: :basic,
description: "Controls randomness in responses"
},
"max_tokens" => %{
type: :integer,
label: "Max Tokens",
min: 1,
field: :max_tokens,
group: :basic,
description: "Maximum tokens to generate"
},
"top_p" => %{
type: :float,
label: "Top P",
min: 0,
max: 1,
step: 0.1,
field: :top_p,
group: :basic,
description: "Nucleus sampling threshold"
},
"top_k" => %{
type: :integer,
label: "Top K",
min: 1,
field: :top_k,
group: :basic,
description: "Top-k sampling parameter"
},
# Advanced parameters
"frequency_penalty" => %{
type: :float,
label: "Frequency Penalty",
min: -2,
max: 2,
step: 0.1,
field: :frequency_penalty,
group: :advanced,
description: "Penalize frequent tokens"
},
"presence_penalty" => %{
type: :float,
label: "Presence Penalty",
min: -2,
max: 2,
step: 0.1,
field: :presence_penalty,
group: :advanced,
description: "Penalize tokens already present"
},
"repetition_penalty" => %{
type: :float,
label: "Repetition Penalty",
min: 0,
max: 2,
step: 0.1,
field: :repetition_penalty,
group: :advanced,
description: "Penalize repeated sequences"
},
"seed" => %{
type: :integer,
label: "Seed",
field: :seed,
group: :advanced,
placeholder: "Random",
description: "For reproducible outputs"
},
"stop" => %{
type: :string_list,
label: "Stop Sequences",
field: :stop,
group: :advanced,
placeholder: "One per line",
description: "Sequences that stop generation"
}
}
end
@doc """
Returns parameters supported by the model, filtered to ones we have UI for.
Groups them by :basic and :advanced.
"""
def get_supported_params(nil) do
# No model selected - show all parameters
definitions = parameter_definitions()
group_parameters(Map.keys(definitions), definitions)
end
def get_supported_params(%AIModel{} = model) do
definitions = parameter_definitions()
supported_keys = Enum.filter(model.supported_parameters, &Map.has_key?(definitions, &1))
group_parameters(supported_keys, definitions)
end
def get_supported_params(model) when is_map(model) do
supported = model["supported_parameters"] || []
definitions = parameter_definitions()
supported_keys = Enum.filter(supported, &Map.has_key?(definitions, &1))
group_parameters(supported_keys, definitions)
end
defp group_parameters(keys, definitions) do
keys
|> Enum.map(fn key -> {key, definitions[key]} end)
|> Enum.group_by(fn {_key, def} -> def.group end)
|> Map.new(fn {group, params} ->
{group, Enum.sort_by(params, fn {key, _} -> key end)}
end)
end
@doc """
Gets the max tokens limit for the selected model.
"""
def model_max_tokens(nil), do: nil
def model_max_tokens(%AIModel{} = model) do
model.max_completion_tokens || model.context_length
end
def model_max_tokens(model) when is_map(model) do
model["max_completion_tokens"] || model["context_length"]
end
@doc """
Formats a number with thousands separators.
"""
def format_number(nil), do: "0"
def format_number(num) when is_integer(num), do: Integer.to_string(num) |> add_commas()
def format_number(num) when is_float(num), do: round(num) |> Integer.to_string() |> add_commas()
def format_number(num) when is_binary(num), do: num
defp add_commas(str) do
str
|> String.reverse()
|> String.graphemes()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
end