defmodule PhoenixKitAI.Web.Endpoints do @moduledoc """ LiveView for AI endpoints management. This module provides a comprehensive interface for managing AI endpoints in PhoenixKit. Each endpoint is a unified configuration containing provider credentials, model selection, and generation parameters. ## Features - **Endpoint Management**: Add, edit, delete, enable/disable AI endpoints - **Usage Statistics**: View request history and token usage ## Route This LiveView is mounted at `{prefix}/admin/ai` and requires appropriate admin permissions. """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext require Logger alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Format alias PhoenixKitAI, as: AI alias PhoenixKitAI.Endpoint alias PhoenixKitAI.Web.AuthHelpers alias PhoenixKitAI.Web.SortHelpers # Static field list (atoms only) — used for compile-time validation # of incoming `?sort=…` params. Labels live in `sort_options/0` below # so `gettext/1` sees a literal string at extract time. @sort_field_keys [ :sort_order, :inserted_at, :name, :enabled, :model, :usage, :tokens, :cost, :last_used ] defp sort_options do [ {:sort_order, gettext("Manual")}, {:inserted_at, gettext("Created")}, {:name, gettext("Name")}, {:enabled, gettext("Status")}, {:model, gettext("Model")}, {:usage, gettext("Requests")}, {:tokens, gettext("Tokens")}, {:cost, gettext("Cost")}, {:last_used, gettext("Last Used")} ] end @page_size 20 @impl true def mount(_params, session, socket) do current_path = get_current_path(socket, session) project_title = Settings.get_project_title() # Subscribe to real-time updates if connected?(socket) do AI.subscribe_endpoints() AI.subscribe_requests() end # No DB queries here — `mount/3` runs twice (HTTP + WebSocket). # `has_endpoints` and the data load happen in `handle_params/3`. socket = socket |> assign(:current_path, current_path) |> assign(:page_title, "AI Endpoints") |> assign(:project_title, project_title) |> assign(:endpoints, []) |> assign(:endpoint_stats, %{}) |> assign(:integrations_by_uuid, %{}) |> assign(:has_endpoints, false) |> assign(:sort_by, :inserted_at) |> assign(:sort_dir, :desc) |> assign(:sort_options, sort_options()) |> assign(:page, 1) |> assign(:page_size, @page_size) |> assign(:total_endpoints, 0) |> assign(:active_tab, "endpoints") |> assign(:usage_loaded, false) |> assign(:usage_stats, nil) |> assign(:usage_requests, []) |> assign(:usage_page, 1) |> assign(:usage_total_requests, 0) |> assign(:selected_request, nil) # Usage tab filters and sorting |> assign(:usage_sort_by, :inserted_at) |> assign(:usage_sort_dir, :desc) |> assign(:usage_filter_endpoint, nil) |> assign(:usage_filter_model, nil) |> assign(:usage_filter_status, nil) |> assign(:usage_filter_source, nil) |> assign(:usage_filter_date, "7d") |> assign(:usage_filter_options, %{endpoints: [], models: [], statuses: [], sources: []}) {:ok, socket} end @impl true def handle_params(params, uri, socket) do # Redirect /admin/ai to /admin/ai/endpoints if socket.assigns.live_action == :index do # Build locale-aware redirect path from the current URI base_path = URI.parse(uri).path |> String.trim_trailing("/") {:noreply, push_navigate(socket, to: base_path <> "/endpoints?sort=id&dir=asc", replace: true)} else # Determine active tab from live_action tab = case socket.assigns.live_action do :usage -> "usage" :endpoints -> "endpoints" _ -> "endpoints" end # Update current_path based on actual URI for proper nav highlighting current_path = URI.parse(uri).path socket = socket |> assign(:active_tab, tab) |> assign(:current_path, current_path) # Apply tab-specific params socket = case tab do "endpoints" -> {sort_by, sort_dir, page} = parse_sort_params(params) socket |> assign(:sort_by, sort_by) |> assign(:sort_dir, sort_dir) |> assign(:page, page) |> maybe_reload_endpoints(tab) "usage" -> {sort_by, sort_dir, filters} = parse_usage_params(params) socket |> assign(:usage_sort_by, sort_by) |> assign(:usage_sort_dir, sort_dir) |> assign(:usage_filter_endpoint, filters.endpoint) |> assign(:usage_filter_model, filters.model) |> assign(:usage_filter_status, filters.status) |> assign(:usage_filter_source, filters.source) |> assign(:usage_filter_date, filters.date) |> load_usage_data() _ -> socket end {:noreply, socket} end end @valid_sort_fields Enum.map(@sort_field_keys, &Atom.to_string/1) defp parse_sort_params(params) do SortHelpers.parse_sort_params(params, valid_fields: @valid_sort_fields, default_sort: :inserted_at, default_dir: :asc ) end @valid_usage_sort_fields ~w(inserted_at endpoint_name model total_tokens latency_ms cost_cents status) defp parse_usage_params(params) do sort_by = case params["sort"] do field when is_binary(field) and field in @valid_usage_sort_fields -> String.to_existing_atom(field) _ -> :inserted_at end sort_dir = case params["dir"] do "asc" -> :asc "desc" -> :desc _ -> :desc end filters = %{ endpoint: parse_endpoint_filter(params["endpoint"]), model: parse_string_param(params["model"]), status: parse_string_param(params["status"]), source: parse_string_param(params["source"]), date: parse_date_filter(params["date"]) } {sort_by, sort_dir, filters} end @valid_date_filters ~w(today 7d 30d all) defp parse_date_filter(nil), do: "7d" defp parse_date_filter(""), do: "7d" defp parse_date_filter(value) when value in @valid_date_filters, do: value defp parse_date_filter(_), do: "7d" # Endpoint filter — always a UUID string in the current schema # (`Request.endpoint_uuid`). Earlier versions of this column were # an integer ID, which is why this helper used to coerce numeric # strings to integers. Now the consumer (`maybe_filter_by/3` on # `:endpoint_uuid`) only accepts binary, so we keep the value as # a string. Empty / nil → nil so `maybe_filter_by` skips the clause. defp parse_endpoint_filter(nil), do: nil defp parse_endpoint_filter(""), do: nil defp parse_endpoint_filter(value) when is_binary(value), do: value defp parse_string_param(nil), do: nil defp parse_string_param(""), do: nil defp parse_string_param(value), do: value defp build_usage_url(sort_by, sort_dir, assigns) do # Only include date in URL if it's not the default date_param = if assigns[:usage_filter_date] != "7d", do: assigns[:usage_filter_date], else: nil params = [ {"sort", Atom.to_string(sort_by)}, {"dir", Atom.to_string(sort_dir)} ] |> maybe_add_url_param("endpoint", assigns[:usage_filter_endpoint]) |> maybe_add_url_param("model", assigns[:usage_filter_model]) |> maybe_add_url_param("status", assigns[:usage_filter_status]) |> maybe_add_url_param("source", assigns[:usage_filter_source]) |> maybe_add_url_param("date", date_param) query = URI.encode_query(params) PhoenixKitAI.Routes.ai_path() <> "/usage?#{query}" end defp maybe_add_url_param(params, _key, nil), do: params defp maybe_add_url_param(params, key, value), do: params ++ [{key, to_string(value)}] defp maybe_reload_endpoints(socket, "endpoints") do reload_endpoints(socket) end defp maybe_reload_endpoints(socket, _tab), do: socket # =========================================== # ENDPOINT ACTIONS # =========================================== @impl true def handle_event("toggle_endpoint", %{"uuid" => uuid}, socket) do endpoint = AI.get_endpoint!(uuid) case AI.update_endpoint(endpoint, %{enabled: !endpoint.enabled}, actor_opts(socket)) do {:ok, updated} -> message = if updated.enabled, do: gettext("Endpoint enabled"), else: gettext("Endpoint disabled") {:noreply, socket |> reload_endpoints() |> put_flash(:info, message)} {:error, _changeset} -> {:noreply, put_flash(socket, :error, gettext("Failed to update endpoint"))} end end @impl true def handle_event("delete_endpoint", %{"uuid" => uuid}, socket) do endpoint = AI.get_endpoint!(uuid) case AI.delete_endpoint(endpoint, actor_opts(socket)) do {:ok, _} -> socket = reload_endpoints(socket) {:noreply, socket |> assign(:has_endpoints, not Enum.empty?(socket.assigns.endpoints)) |> put_flash(:info, gettext("Endpoint deleted"))} {:error, _} -> {:noreply, put_flash(socket, :error, gettext("Failed to delete endpoint"))} end end @impl true def handle_event("sort", %{"by" => field}, socket) do field = String.to_existing_atom(field) current_sort_by = socket.assigns.sort_by current_sort_dir = socket.assigns.sort_dir # Toggle direction if same field, otherwise default to desc sort_dir = if field == current_sort_by do if current_sort_dir == :asc, do: :desc, else: :asc else :desc end # Reset to page 1 when sorting changes, update URL with sort params path = PhoenixKitAI.Routes.ai_path() <> "/endpoints?sort=#{field}&dir=#{sort_dir}" {:noreply, push_patch(socket, to: path)} end # `<.sort_selector>` event. The field select sends `sort_by` only; the # direction button sends `sort_dir` only. Each missing param is read # from `socket.assigns` so two events in flight can't clobber each # other (race-free by construction). See sort_selector moduledoc. @impl true def handle_event("sort_form", params, socket) do field_str = params["sort_by"] || Atom.to_string(socket.assigns.sort_by) dir_str = params["sort_dir"] || Atom.to_string(socket.assigns.sort_dir) field = SortHelpers.parse_sort_field(field_str, @valid_sort_fields, socket.assigns.sort_by) dir = SortHelpers.parse_sort_dir(dir_str, socket.assigns.sort_dir) path = PhoenixKitAI.Routes.ai_path() <> "/endpoints?sort=#{field}&dir=#{dir}" {:noreply, push_patch(socket, to: path)} end @impl true def handle_event("goto_page", %{"page" => page_str}, socket) do case Integer.parse(page_str) do {page, ""} when page > 0 -> sort_by = socket.assigns.sort_by sort_dir = socket.assigns.sort_dir path = build_endpoints_url(sort_by, sort_dir, page) {:noreply, push_patch(socket, to: path)} _ -> {:noreply, socket} end end @impl true def handle_event("reorder_endpoints", %{"ordered_ids" => ordered_ids} = params, socket) when is_list(ordered_ids) do moved_id = params["moved_id"] case AI.reorder_endpoints(ordered_ids, actor_opts(socket)) do :ok -> {:noreply, socket |> push_event("sortable:flash", %{uuid: moved_id, status: "ok"}) |> reload_endpoints()} {:error, :too_many_uuids} -> {:noreply, socket |> put_flash(:error, gettext("Too many endpoints to reorder at once.")) |> push_event("sortable:flash", %{uuid: moved_id, status: "error"}) |> reload_endpoints()} end end # =========================================== # USAGE TAB # =========================================== @impl true def handle_event("load_more_requests", _params, socket) do page = socket.assigns.usage_page + 1 opts = [ page: page, page_size: 20, sort_by: socket.assigns.usage_sort_by, sort_dir: socket.assigns.usage_sort_dir, preload: [:prompt] ] |> maybe_add_filter(:endpoint_uuid, socket.assigns.usage_filter_endpoint) |> maybe_add_filter(:model, socket.assigns.usage_filter_model) |> maybe_add_filter(:status, socket.assigns.usage_filter_status) |> maybe_add_filter(:source, socket.assigns.usage_filter_source) |> maybe_add_filter(:since, date_filter_to_datetime(socket.assigns.usage_filter_date)) {new_requests, _total} = AI.list_requests(opts) socket = socket |> assign(:usage_page, page) |> assign(:usage_requests, socket.assigns.usage_requests ++ new_requests) {:noreply, socket} end @impl true def handle_event("usage_sort", %{"by" => field}, socket) do field = String.to_existing_atom(field) current_sort_by = socket.assigns.usage_sort_by current_sort_dir = socket.assigns.usage_sort_dir sort_dir = if field == current_sort_by do if current_sort_dir == :asc, do: :desc, else: :asc else :desc end path = build_usage_url(field, sort_dir, socket.assigns) {:noreply, push_patch(socket, to: path)} end @impl true def handle_event("usage_filter", params, socket) do # Extract values with defaults (some fields may be hidden when < 2 options) endpoint = Map.get(params, "endpoint", "") model = Map.get(params, "model", "") status = Map.get(params, "status", "") source = Map.get(params, "source", "") date = Map.get(params, "date", "7d") # Build new assigns for URL generation new_assigns = %{ usage_sort_by: socket.assigns.usage_sort_by, usage_sort_dir: socket.assigns.usage_sort_dir, usage_filter_endpoint: if(endpoint == "", do: nil, else: parse_endpoint_filter(endpoint)), usage_filter_model: if(model == "", do: nil, else: model), usage_filter_status: if(status == "", do: nil, else: status), usage_filter_source: if(source == "", do: nil, else: source), usage_filter_date: if(date == "", do: "7d", else: date) } path = build_usage_url(new_assigns.usage_sort_by, new_assigns.usage_sort_dir, new_assigns) {:noreply, push_patch(socket, to: path)} end @impl true def handle_event("clear_usage_filters", _params, socket) do path = build_usage_url(socket.assigns.usage_sort_by, socket.assigns.usage_sort_dir, %{ usage_filter_endpoint: nil, usage_filter_model: nil, usage_filter_status: nil, usage_filter_source: nil, usage_filter_date: "7d" }) {:noreply, push_patch(socket, to: path)} end @impl true def handle_event("show_request_details", %{"uuid" => uuid}, socket) do request = Enum.find(socket.assigns.usage_requests, fn r -> to_string(r.uuid) == uuid end) {:noreply, assign(socket, :selected_request, request)} end @impl true def handle_event("close_request_details", _params, socket) do {:noreply, assign(socket, :selected_request, nil)} end # =========================================== # PUBSUB HANDLERS - Real-time updates # =========================================== @impl true def handle_info({event, _endpoint}, socket) when event in [:endpoint_created, :endpoint_updated, :endpoint_deleted] do # Reload endpoints list when any endpoint changes {:noreply, reload_endpoints(socket)} end @impl true def handle_info({:request_created, _request}, socket) do # Reload usage data if on usage tab socket = if socket.assigns.active_tab == "usage" do socket |> reload_usage_requests() |> update_usage_stats() else socket end {:noreply, socket} end # Catch-all for unmatched messages (PubSub from other modules, late # replies after navigation, etc.). Log at :debug per the workspace # sync precedent — never silently swallow a message we didn't expect. @impl true def handle_info(msg, socket) do Logger.debug(fn -> "[PhoenixKitAI.Web.Endpoints] unhandled handle_info: #{inspect(msg)}" end) {:noreply, socket} end # =========================================== # PRIVATE HELPERS # =========================================== defp reload_endpoints(socket) do sort_by = socket.assigns.sort_by sort_dir = socket.assigns.sort_dir page = socket.assigns.page page_size = socket.assigns.page_size {endpoints, total} = AI.list_endpoints( sort_by: sort_by, sort_dir: sort_dir, page: page, page_size: page_size ) endpoint_stats = AI.get_endpoint_usage_stats() # Map of integration_uuid → connection (`%{name, data}`) for every # provider this module supports. Loaded once so per-endpoint # rendering can pull both the health status AND the integration # name + masked api_key without an N+1 of # `Integrations.connected?/1` / `get_credentials/1` calls. An # endpoint whose `integration_uuid` is not a key in this map is # an orphan (the integration row was deleted) — including a # Mistral- or DeepSeek-bound endpoint whose connection lives under # a non-openrouter provider key. integrations_by_uuid = Endpoint.valid_providers() |> Enum.flat_map(&PhoenixKit.Integrations.list_connections/1) |> Map.new(fn conn -> {conn.uuid, conn} end) socket |> assign(:endpoints, endpoints) |> assign(:endpoint_stats, endpoint_stats) |> assign(:integrations_by_uuid, integrations_by_uuid) |> assign(:total_endpoints, total) |> assign(:has_endpoints, total > 0) end defp build_endpoints_url(sort_by, sort_dir, page) do base = PhoenixKitAI.Routes.ai_path() <> "/endpoints?sort=#{sort_by}&dir=#{sort_dir}" if page > 1 do base <> "&page=#{page}" else base end end defp load_usage_data(socket) do # Only load stats and filter options on first load socket = if socket.assigns.usage_loaded do socket else stats = AI.get_dashboard_stats() filter_options = AI.get_request_filter_options() socket |> assign(:usage_loaded, true) |> assign(:usage_stats, stats) |> assign(:usage_filter_options, filter_options) end # Always reload requests with current filters/sort reload_usage_requests(socket) end defp reload_usage_requests(socket) do opts = [ page: 1, page_size: 20, sort_by: socket.assigns.usage_sort_by, sort_dir: socket.assigns.usage_sort_dir, preload: [:prompt] ] |> maybe_add_filter(:endpoint_uuid, socket.assigns.usage_filter_endpoint) |> maybe_add_filter(:model, socket.assigns.usage_filter_model) |> maybe_add_filter(:status, socket.assigns.usage_filter_status) |> maybe_add_filter(:source, socket.assigns.usage_filter_source) |> maybe_add_filter(:since, date_filter_to_datetime(socket.assigns.usage_filter_date)) {requests, total} = AI.list_requests(opts) socket |> assign(:usage_requests, requests) |> assign(:usage_total_requests, total) |> assign(:usage_page, 1) end defp update_usage_stats(socket) do stats = AI.get_dashboard_stats() filter_options = AI.get_request_filter_options() socket |> assign(:usage_stats, stats) |> assign(:usage_filter_options, filter_options) end defp maybe_add_filter(opts, _key, nil), do: opts defp maybe_add_filter(opts, _key, ""), do: opts defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value) # Convert date filter string to DateTime for the :since filter defp date_filter_to_datetime("today") do Date.utc_today() |> DateTime.new!(~T[00:00:00], "Etc/UTC") end defp date_filter_to_datetime("7d") do UtilsDate.utc_now() |> DateTime.add(-7, :day) end defp date_filter_to_datetime("30d") do UtilsDate.utc_now() |> DateTime.add(-30, :day) end defp date_filter_to_datetime("all"), do: nil defp date_filter_to_datetime(_), do: nil defp get_current_path(socket, session) do case socket.assigns do %{__changed__: _, current_path: path} when is_binary(path) -> path _ -> session["current_path"] || PhoenixKitAI.Routes.ai_path() end end defp format_bytes(bytes), do: Format.bytes(bytes, unknown: "-") # Activity attribution — passed through to AI.update_endpoint/3 and # AI.delete_endpoint/2 so the mutation is logged against the right # actor. See PhoenixKitAI moduledoc for the activity logging pattern. defp actor_opts(socket), do: AuthHelpers.actor_opts(socket) end