Current section

Files

Jump to
phoenix_kit_ai lib phoenix_kit_ai web endpoint_form.html.heex
Raw

lib/phoenix_kit_ai/web/endpoint_form.html.heex

<div class="flex flex-col w-full px-4 py-6">
<% subtitle =
if @endpoint,
do: gettext("Update your AI endpoint configuration"),
else:
gettext(
"Create a new AI endpoint with provider credentials, model selection, and parameters"
) %>
<.admin_page_header title={@page_title} subtitle={subtitle} />
<%!-- Form Content (capped at 5xl: wide enough for the 4-col parameter
grid, narrow enough that single-line inputs don't stretch absurdly
on ultrawide displays). --%>
<div class="max-w-5xl mx-auto w-full">
<.form
for={@form}
phx-change="validate"
phx-submit="save"
class="space-y-6"
autocomplete="off"
>
<%!-- Basic Info --%>
<.form_section title={gettext("Basic Information")}>
<div class="space-y-4 mt-4">
<.input
field={@form[:name]}
type="text"
label={gettext("Name") <> " *"}
placeholder={gettext("e.g., Claude Fast")}
required
/>
<.textarea
field={@form[:description]}
errors={
Enum.map(@form[:description].errors,
&PhoenixKitWeb.Components.Core.Input.translate_error/1)
}
label={gettext("Description")}
placeholder={gettext("Optional description of this endpoint's purpose")}
rows="2"
/>
<%!-- Status toggle. Edit-only — new endpoints inherit the
schema's `enabled: true` default, so a "create + disabled"
dance isn't a real flow we want to expose.
Hidden `value="false"` input is required: unchecked checkboxes
don't submit any value, so without this the params would
lack `enabled` after unchecking, the changeset would fall
back to the previous DB value, and the toggle would snap
back to "on" on re-render. Same pattern as core's
<.checkbox> component. --%>
<div :if={@endpoint} class="form-control py-2">
<label class="flex items-center gap-4 cursor-pointer">
<input type="hidden" name="endpoint[enabled]" value="false" />
<input
type="checkbox"
name="endpoint[enabled]"
value="true"
checked={
Phoenix.HTML.Form.normalize_value("checkbox", @form[:enabled].value)
}
class="toggle toggle-success"
/>
<div class="select-none">
<div class="font-medium">{gettext("Enabled")}</div>
<div class="text-xs text-base-content/60">
{gettext("Disabled endpoints can't be used for AI requests.")}
</div>
</div>
</label>
</div>
</div>
</.form_section>
<%!-- Provider & API Key --%>
<.form_section title={gettext("Provider Configuration")}>
<:subtitle>
{gettext("Credentials live in")}
<.pk_link navigate="/admin/settings/integrations" class="link link-primary">
{gettext("Settings → Integrations")}
</.pk_link>. {gettext("Add new ones there and they'll appear in the picker.")}
</:subtitle>
<div class="mt-4 max-w-md">
<.select
field={@form[:provider]}
label={gettext("Provider")}
options={@provider_options}
/>
</div>
<div class="mt-4">
<.integration_picker
id="endpoint-provider-picker"
connections={@provider_connections}
selected={@selected_uuids}
provider={@current_provider}
compact={true}
on_select="select_provider_connection"
empty_url={PhoenixKit.Utils.Routes.path("/admin/settings/integrations/new")}
/>
</div>
<%!-- Legacy API key recovery field.
Renders only when the endpoint is in the "stuck migration"
state — the legacy `api_key` column is populated but no
Integration is selected (`integration_uuid` is nil). This
happens after a partial migration failure or for
pre-migration endpoints whose migrate_legacy/0 pass
didn't complete.
The field is read-only and password-masked by default
(eye icon to reveal). A copy button puts the value on
the clipboard so the operator can paste it into a new
Integration via Settings → Integrations → Add.
On save, when the user picks an Integration, the
changeset clears `api_key` to NULL in the same DB
transaction (`Endpoint.maybe_clear_legacy_api_key/1`) —
so this recovery card disappears and stays gone, even
if the integration is later unselected. By design: once
the operator has explicitly migrated, falling back to
a stale legacy key is misleading more than helpful. --%>
<div
:if={
@endpoint && @endpoint.api_key && @endpoint.api_key != "" &&
is_nil(@endpoint.integration_uuid)
}
class="mt-4 rounded-lg border border-warning/40 bg-warning/5 p-4"
>
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-exclamation-triangle" class="w-4 h-4 text-warning" />
<span class="font-medium text-sm">{gettext("Legacy API key (recovery)")}</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
{gettext(
"This endpoint still has an api_key from before the Integrations system. Copy it and paste it into a new Integration above to migrate. The field will be removed once you connect this endpoint to an Integration."
)}
</p>
<%!-- Plain `type="text"` — this is an API key, not a
site login, so we don't want browsers / password
managers to vault it. Plaintext display is also
the operator's intent here (they're trying to
read the value to copy it). --%>
<div class="flex gap-2 items-center">
<input
id="legacy-api-key-field"
type="text"
value={@endpoint.api_key}
readonly
class="input input-sm input-bordered flex-1 font-mono text-xs"
autocomplete="off"
/>
<button
type="button"
id="legacy-api-key-copy"
phx-hook="CopyToClipboard"
data-copy-target="#legacy-api-key-field"
class="btn btn-sm btn-outline"
>
<span data-copy-idle class="inline-flex items-center gap-1">
<.icon name="hero-clipboard" class="w-4 h-4" />
{gettext("Copy")}
</span>
<span data-copy-feedback class="hidden inline-flex items-center gap-1 text-success">
<.icon name="hero-check" class="w-4 h-4" />
{gettext("Copied!")}
</span>
</button>
</div>
</div>
</.form_section>
<%!-- Model Selection Card.
Header always renders so the operator can see the form
has more sections beyond Provider Configuration. Body
content is gated on `@active_connection` — without a
picked integration, the model fetch can't run, so the
card body becomes a short placeholder pointing at the
picker above. --%>
<.form_section
title={gettext("Model Selection")}
class={if !@active_connection, do: "opacity-60"}
>
<p :if={!@active_connection} class="text-base-content/50 text-sm">
{gettext("Pick an integration above to load available models.")}
</p>
<% current_model_id = @form.params["model"] || (@endpoint && @endpoint.model)
has_model = current_model_id && current_model_id != "" %>
<%= if @active_connection do %>
<%!-- Current Model panel.
Three render paths:
1. **Selected model is in the loaded list** — hoist
the rich `<.model_card>` out of the grid below
and render it here with a Clear (X) button. The
grid then filters it out so the same card never
appears twice on the page.
2. **Saved model ID isn't in the loaded list**
(drift — provider changed, model deprecated, or
fetch failed). The card can't be hoisted because
the data isn't there; fall back to a compact ID +
drift-warning row.
3. **No model selected** — friendly empty state. --%>
<%!-- Model-type filter. Drives which `model_type` the model
fetch uses (Chat vs Text-to-Speech vs Image Generation).
Has its own phx-change so it doesn't ride the form's
"validate". --%>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">{gettext("Model Type")}</span>
</label>
<select
name="model_type"
phx-change="select_model_type"
class="select select-bordered w-full max-w-xs"
>
<option value="text" selected={@model_type == :text}>
{gettext("Chat / Completion")}
</option>
<option
:if={PhoenixKitAI.Endpoint.tts_model_picker?(@current_provider)}
value="tts"
selected={@model_type == :tts}
>
{gettext("Text-to-Speech")}
</option>
<option
:if={PhoenixKitAI.Endpoint.image_gen_model_picker?(@current_provider)}
value="image_gen"
selected={@model_type == :image_gen}
>
{gettext("Image Generation")}
</option>
</select>
<p class="label-text-alt text-base-content/50 mt-1">
<%= case @model_type do %>
<% :tts -> %>
{gettext(
"Filters the model list below. Text-to-Speech endpoints are called via PhoenixKitAI.speak/3."
)}
<% :image_gen -> %>
{gettext(
"Filters the model list below. Image generation endpoints are called via PhoenixKitAI.generate_image/3."
)}
<% _ -> %>
<%= if PhoenixKitAI.Endpoint.tts_model_picker?(@current_provider) do %>
{gettext("Filters the model list below.")}
<% else %>
{gettext(
"xAI's text-to-speech isn't model-based — any endpoint already works with PhoenixKitAI.speak/3."
)}
<% end %>
<% end %>
</p>
</div>
<%!-- Default voice — TTS only. Stored in provider_settings and
used by speak/3 when the caller passes no voice. When the
provider exposes a voice catalogue (Mistral /audio/voices,
fetched into @voices) we render a picker; otherwise we fall
back to a free-text field (e.g. OpenRouter preset names). --%>
<div :if={@model_type == :tts} class="form-control mt-4">
<% current_voice =
get_in(@form.params, ["provider_settings", "voice"]) ||
(@endpoint && get_in(@endpoint.provider_settings || %{}, ["voice"])) || "" %>
<label class="label">
<span class="label-text font-medium">{gettext("Default Voice")}</span>
</label>
<%= if @voices != [] do %>
<%!-- Two-level picker (Mistral Studio style): choose a
speaker, then a mood. Each "name" is "Speaker - Mood";
the resolved slug is what gets submitted/stored. The
speaker <select> drives @selected_speaker via its own
phx-change; the mood <select> is the endpoint field. --%>
<% speakers = PhoenixKitAI.Web.EndpointForm.voice_speakers(@voices) %>
<% selected_speaker_key =
@selected_speaker ||
PhoenixKitAI.Web.EndpointForm.speaker_for_voice(speakers, current_voice) ||
(List.first(speakers) && List.first(speakers).key) %>
<% active_speaker = Enum.find(speakers, &(&1.key == selected_speaker_key)) %>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label py-1">
<span class="label-text text-sm">{gettext("Speaker")}</span>
</label>
<select
name="voice_speaker"
phx-change="select_voice_speaker"
class="select select-bordered"
>
<%= for sp <- speakers do %>
<option value={sp.key} selected={sp.key == selected_speaker_key}>
{sp.label}
</option>
<% end %>
</select>
</div>
<div>
<label class="label py-1">
<span class="label-text text-sm">{gettext("Mood")}</span>
</label>
<select name="endpoint[provider_settings][voice]" class="select select-bordered">
<option value="" selected={current_voice == ""}>{gettext("— Select —")}</option>
<%= for voice <- (active_speaker && active_speaker.voices) || [] do %>
<option value={voice.slug} selected={current_voice == voice.slug}>
{voice.mood}
</option>
<% end %>
</select>
</div>
</div>
<%!-- Mistral requires a voice (or ref_audio) — /audio/speech
returns 400 without one. The picker only renders for
Mistral, so this required-hint is scoped correctly. --%>
<p :if={current_voice == ""} class="label-text-alt text-warning mt-1">
{gettext("Pick a mood — this provider can't generate speech without a voice.")}
</p>
<p :if={current_voice != ""} class="label-text-alt text-base-content/50 mt-1">
{gettext("Used when speak/3 is called without an explicit voice.")}
</p>
<% else %>
<input
type="text"
name="endpoint[provider_settings][voice]"
value={current_voice}
class="input input-bordered w-full max-w-md"
placeholder={gettext("e.g. casual_male")}
/>
<p class="label-text-alt text-base-content/50 mt-1">
{gettext(
"Optional provider-specific voice id. Used when speak/3 is called without an explicit voice."
)}
</p>
<% end %>
</div>
<%!-- Default size / quality (OpenAI / OpenRouter) or aspect
ratio / resolution (xAI) — Image Generation only.
OpenAI-shaped fields live on the endpoint columns;
xAI's live under provider_settings and are applied by
generate_image/3 as :aspect_ratio / :resolution. --%>
<div :if={@model_type == :image_gen} class="form-control mt-4">
<%= if is_binary(@current_provider) and
PhoenixKitAI.Endpoint.base_provider(@current_provider) == "xai" do %>
<% current_aspect =
get_in(@form.params, ["provider_settings", "aspect_ratio"]) ||
(@endpoint && get_in(@endpoint.provider_settings || %{}, ["aspect_ratio"])) ||
"" %>
<% current_resolution =
get_in(@form.params, ["provider_settings", "resolution"]) ||
(@endpoint && get_in(@endpoint.provider_settings || %{}, ["resolution"])) ||
"" %>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label py-1">
<span class="label-text font-medium">{gettext("Default Aspect Ratio")}</span>
</label>
<select
name="endpoint[provider_settings][aspect_ratio]"
class="select select-bordered"
>
<option value="" selected={current_aspect == ""}>
{gettext("— Provider default —")}
</option>
<%= for {label, value} <- PhoenixKitAI.Endpoint.image_aspect_ratio_options() do %>
<option value={value} selected={current_aspect == value}>{label}</option>
<% end %>
</select>
</div>
<div>
<label class="label py-1">
<span class="label-text font-medium">{gettext("Default Resolution")}</span>
</label>
<select
name="endpoint[provider_settings][resolution]"
class="select select-bordered"
>
<option value="" selected={current_resolution == ""}>
{gettext("— Provider default —")}
</option>
<%= for {label, value} <- PhoenixKitAI.Endpoint.image_resolution_options() do %>
<option value={value} selected={current_resolution == value}>{label}</option>
<% end %>
</select>
</div>
</div>
<p class="label-text-alt text-base-content/50 mt-1">
{gettext(
"Used when generate_image/3 is called without an explicit :aspect_ratio/:resolution."
)}
</p>
<% else %>
<% current_size =
@form.params["image_size"] || (@endpoint && @endpoint.image_size) || "" %>
<% current_quality =
@form.params["image_quality"] || (@endpoint && @endpoint.image_quality) || "" %>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="label py-1">
<span class="label-text font-medium">{gettext("Default Size")}</span>
</label>
<select name="endpoint[image_size]" class="select select-bordered">
<option value="" selected={current_size == ""}>
{gettext("— Provider default —")}
</option>
<%= for {label, value} <- PhoenixKitAI.Endpoint.image_size_options() do %>
<option value={value} selected={current_size == value}>{label}</option>
<% end %>
</select>
</div>
<div>
<label class="label py-1">
<span class="label-text font-medium">{gettext("Default Quality")}</span>
</label>
<select name="endpoint[image_quality]" class="select select-bordered">
<option value="" selected={current_quality == ""}>
{gettext("— Provider default —")}
</option>
<%= for {label, value} <- PhoenixKitAI.Endpoint.image_quality_options() do %>
<option value={value} selected={current_quality == value}>{label}</option>
<% end %>
</select>
</div>
</div>
<p class="label-text-alt text-base-content/50 mt-1">
{gettext(
"Used when generate_image/3 is called without an explicit :size/:quality."
)}
</p>
<% end %>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">{gettext("Current Model")} *</span>
</label>
<%!-- Hidden input to store the actual model value --%>
<input type="hidden" name="endpoint[model]" value={current_model_id || ""} />
<%= cond do %>
<% @selected_model -> %>
<.model_card model={@selected_model} selected={true} show_clear={true} />
<% has_model -> %>
<%!-- Drift: saved ID isn't in the loaded list. --%>
<div class={[
"rounded-lg border p-3 flex items-center justify-between gap-3",
@form.errors[:model] && "border-error",
!@form.errors[:model] && "border-base-300 bg-base-200"
]}>
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">
{current_model_id}
</div>
<div class="mt-1">
<span class="badge badge-warning badge-xs gap-1">
<.icon name="hero-exclamation-triangle" class="w-3 h-3" />
{gettext("Not in current model list")}
</span>
</div>
</div>
<button
type="button"
phx-click="clear_model"
phx-disable-with={gettext("…")}
class="btn btn-ghost btn-sm btn-square shrink-0 [&.phx-click-loading]:pointer-events-none"
aria-label={gettext("Clear model")}
title={gettext("Clear model")}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<% true -> %>
<%!-- Empty state. --%>
<div class={[
"rounded-lg border border-dashed p-6 text-center",
@form.errors[:model] && "border-error bg-error/5",
!@form.errors[:model] && "border-base-300"
]}>
<.icon name="hero-cube" class="w-8 h-8 mx-auto text-base-content/30" />
<p class="text-base-content/50 mt-2">{gettext("No model selected")}</p>
<p class="text-sm text-base-content/40">
{gettext("Choose a model from the list below")}
</p>
</div>
<% end %>
<%= if @form.errors[:model] do %>
<label class="label">
<span class="label-text-alt text-error">
{translate_error(@form.errors[:model])}
</span>
</label>
<% end %>
</div>
<%!-- Model browse area. When a model is hoisted above as
"Current Model", this label reads "Change model";
otherwise "Select model". The grid below excludes
the hoisted card so each model appears once. --%>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">
<%= if has_model do %>
{gettext("Change Model")}
<% else %>
{gettext("Select Model")}
<% end %>
</span>
</label>
<%= if @models_loading do %>
<div class="flex items-center gap-2 py-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/70">{gettext("Loading models...")}</span>
<%!-- "Still loading" hint after 10s. The provider's
/models endpoint is unusually slow or wedged;
surfacing this lets the operator decide to wait
or cancel the picker out instead of staring at
a silent spinner. --%>
<span :if={@models_loading_slow} class="text-base-content/50 text-sm italic">
{gettext("(taking longer than usual — the provider may be slow)")}
</span>
</div>
<% else %>
<%!-- Error pane with retry button. Lets operators
recover from transient upstream failures (5xx,
timeout, rate-limit) without re-picking the
integration. --%>
<div :if={@models_error} class="alert alert-error py-2 mb-2">
<.icon name="hero-exclamation-triangle" class="w-4 h-4" />
<span class="text-sm">{@models_error}</span>
<button
:if={@integration_connected}
type="button"
phx-click="retry_model_fetch"
phx-disable-with={gettext("Retrying…")}
class="btn btn-sm btn-ghost ml-auto"
>
<.icon name="hero-arrow-path" class="w-4 h-4" /> {gettext("Retry")}
</button>
</div>
<%= if Enum.empty?(@models_grouped) do %>
<%!-- Fallback to manual model-ID entry.
Disabled until an integration is connected so
the operator can't type a value into a field
whose action is unreachable. The hint below
explains the gate. --%>
<div class="join w-full">
<input
type="text"
id="manual_model_input"
class="input input-bordered join-item flex-1"
placeholder={gettext("Enter model ID (e.g., anthropic/claude-3-haiku)")}
disabled={!@integration_connected}
phx-hook="ManualModelInput"
/>
<button
type="button"
phx-click="set_manual_model"
phx-disable-with={gettext("Setting…")}
data-manual-model-submit
disabled={!@integration_connected}
class="btn btn-primary join-item [&.phx-click-loading]:pointer-events-none"
>
{gettext("Set Model")}
</button>
</div>
<label class="label">
<span class="label-text-alt text-base-content/50">
{gettext("Connect a %{provider} integration above to load available models",
provider: Endpoint.provider_label(@current_provider)
)}
</span>
</label>
<% else %>
<%!-- Provider Selection. Hidden when only one
provider group exists (direct-API providers
like Mistral / DeepSeek whose model IDs don't
carry a `provider/model` slash) — the LV
auto-selects the sole group so the operator
lands on the model grid directly instead of
clicking through a one-option dropdown. --%>
<%!-- `group` wraps select + loading hint together; the
hint reveals itself via `group-has-[select.phx-change-loading]`
(Tailwind 3.4+ has-selector modifier). When LV adds
.phx-change-loading to the <select> during the
round-trip, the wrapper "has" that descendant, so
descendants with the variant flip on. --%>
<div :if={length(@models_grouped) > 1} class="group">
<label class="select w-full group-has-[select.phx-change-loading]:opacity-60 transition-opacity">
<select
id="provider_picker"
name="provider"
phx-change="select_provider"
phx-hook="ResetSelect"
>
<option value="" selected>
{gettext("Select provider (%{count} available)", count: length(@models_grouped))}
</option>
<%= for {provider, models} <- @models_grouped do %>
<option value={provider} selected={@selected_provider == provider}>
{PhoenixKitAI.OpenRouterClient.humanize_provider(provider)} ({length(
models
)} models)
</option>
<% end %>
</select>
</label>
<div class="hidden group-has-[select.phx-change-loading]:flex items-center gap-2 mt-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-xs"></span>
{gettext("Loading models…")}
</div>
</div>
<%!-- Model Cards (shown after provider selected) --%>
<%= if @selected_provider do %>
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-base-content/70">
{length(@provider_models)} models from {PhoenixKitAI.OpenRouterClient.humanize_provider(
@selected_provider
)}
</span>
<%!-- Only show Clear when there's a provider
dropdown to fall back to. With one group
auto-selected, clearing would orphan the
user with no way to re-enter the grid. --%>
<button
:if={length(@models_grouped) > 1}
type="button"
phx-click="select_provider"
phx-value-provider=""
phx-disable-with={gettext("…")}
class="btn btn-ghost btn-xs [&.phx-click-loading]:pointer-events-none"
>
<.icon name="hero-x-mark" class="w-3 h-3" /> {gettext("Clear")}
</button>
</div>
<%!-- Filter input. Client-side filter via the
data-search-text attribute on each card —
no LV round-trip for keystrokes. Auto-shown
when the grid has more than 6 entries; the
hook scopes itself to the sibling grid by
data-grid-id. --%>
<input
:if={length(@provider_models) > 6}
type="text"
id="model_grid_search"
data-grid-id="model_grid"
phx-hook="ModelGridSearch"
placeholder={gettext("Filter models…")}
class="input input-bordered input-sm w-full mb-2"
/>
<%!-- Grid excludes the currently-selected model
— it's hoisted to the "Current Model" panel
above so each card appears in exactly one
place. Clicking a different card in the
grid selects it (and the previous selection
drops back into the grid on the next
render). --%>
<div id="model_grid" class="grid gap-2 max-h-80 overflow-y-auto pr-1">
<%= for model <- @provider_models, model.id != current_model_id do %>
<.model_card model={model} selected={false} />
<% end %>
</div>
</div>
<% else %>
<p class="text-sm text-base-content/50 mt-2">
{gettext("Select a provider to see available models with pricing details")}
</p>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
</.form_section>
<%!-- Parameters Card - Only shown when model is selected --%>
<% supported_params =
PhoenixKitAI.Web.EndpointForm.get_supported_params(@selected_model) %>
<% basic_params = supported_params[:basic] || [] %>
<% advanced_params = supported_params[:advanced] || [] %>
<% has_model_for_params =
@selected_model || (@form.params["model"] && @form.params["model"] != "") ||
(@endpoint && @endpoint.model) %>
<%= if has_model_for_params && @active_connection do %>
<.form_section title={gettext("Generation Parameters")}>
<%!-- Model Info Banner --%>
<%= if @selected_model do %>
<div class="alert alert-info py-2 mt-2">
<.icon name="hero-information-circle" class="w-4 h-4" />
<span class="text-sm">
{gettext("Context: %{tokens} tokens",
tokens: PhoenixKitAI.Web.EndpointForm.format_number(@selected_model.context_length))}
<%= if @selected_model.max_completion_tokens do %>
{gettext("Max output: %{tokens} tokens",
tokens: PhoenixKitAI.Web.EndpointForm.format_number(@selected_model.max_completion_tokens))}
<% end %>
</span>
</div>
<% end %>
<%!-- Basic Parameters --%>
<%= if basic_params != [] do %>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<%= for {param_key, param_def} <- basic_params do %>
<.param_input
key={param_key}
definition={param_def}
form={@form}
endpoint={@endpoint}
selected_model={@selected_model}
/>
<% end %>
</div>
<% end %>
<%!-- Enable Thinking Checkbox --%>
<% reasoning_enabled =
@form.params["reasoning_enabled"] == "true" ||
@form.params["reasoning_enabled"] == true ||
(@endpoint && @endpoint.reasoning_enabled == true) %>
<div class="bg-base-200 rounded-lg p-4 mt-4">
<div class="form-control">
<.checkbox
name="endpoint[reasoning_enabled]"
checked={reasoning_enabled}
phx-click="toggle_reasoning"
>
<.icon name="hero-light-bulb" class="w-4 h-4 inline mr-1" />
{gettext("Enable Thinking")}
<:description>
{gettext("For models like DeepSeek R1, Qwen QwQ that support chain-of-thought reasoning")}
</:description>
</.checkbox>
</div>
<%!-- Reasoning Options (shown when enabled) --%>
<%= if reasoning_enabled do %>
<div class="border-t border-base-300 mt-3 pt-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%!-- Reasoning Effort --%>
<.select
field={@form[:reasoning_effort]}
label={gettext("Effort Level")}
prompt={gettext("Default (medium)")}
options={PhoenixKitAI.Endpoint.reasoning_effort_options()}
class="select-sm"
/>
<%!-- Reasoning Max Tokens --%>
<.input
field={@form[:reasoning_max_tokens]}
type="number"
label={gettext("Token Limit")}
min="1024"
max="32000"
step="1024"
class="input-sm"
placeholder="1024-32000"
/>
<%!-- Hide Reasoning Toggle --%>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-sm">{gettext("Visibility")}</span>
</label>
<.checkbox
name="endpoint[reasoning_exclude]"
checked={
@form.params["reasoning_exclude"] == "true" ||
@form.params["reasoning_exclude"] == true ||
(@endpoint && @endpoint.reasoning_exclude == true)
}
label={gettext("Hide from response")}
class="checkbox-sm"
/>
</div>
</div>
</div>
<% end %>
</div>
<%!-- Advanced Parameters --%>
<%= if advanced_params != [] do %>
<div class="collapse collapse-arrow bg-base-200 mt-4">
<input
type="checkbox"
name="_advanced_open"
phx-update="ignore"
id="advanced-params-toggle"
/>
<div class="collapse-title font-medium">Advanced Parameters</div>
<div class="collapse-content">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 pt-2">
<%= for {param_key, param_def} <- advanced_params do %>
<.param_input
key={param_key}
definition={param_def}
form={@form}
endpoint={@endpoint}
selected_model={@selected_model}
size="sm"
/>
<% end %>
</div>
</div>
</div>
<% end %>
</.form_section>
<% else %>
<%!-- Placeholder for two states:
* No integration picked yet — operator can't reach
the model picker until they do.
* Integration picked but no model selected. --%>
<.form_section title={gettext("Generation Parameters")} class="opacity-60">
<p class="text-base-content/50 text-sm">
<%= if @active_connection do %>
{gettext("Select a model above to configure generation parameters. Available parameters depend on the selected model.")}
<% else %>
{gettext("Pick an integration above, then a model, to configure generation parameters.")}
<% end %>
</p>
</.form_section>
<% end %>
<%!-- Optional Settings Card —
HTTP-Referer / X-Title are OpenRouter-specific request
headers (used for app rankings on openrouter.ai).
Mistral and DeepSeek don't honour them, so don't surface
the fields there.
Header always renders (greyed when no integration is
picked) so the operator can see this section exists.
Body shows only when the operator has picked an
OpenRouter integration. --%>
<% show_optional_body = @active_connection && @current_provider == "openrouter" %>
<.form_section
title={gettext("Optional Provider Settings")}
class={if !show_optional_body, do: "opacity-60"}
>
<p :if={!show_optional_body} class="text-base-content/50 text-sm">
<%= cond do %>
<% is_nil(@active_connection) -> %>
{gettext("Pick an integration above to see optional provider-specific settings.")}
<% @current_provider != "openrouter" -> %>
{gettext("These settings apply only to OpenRouter.")}
<% true -> %>
{gettext("No optional settings for this provider.")}
<% end %>
</p>
<%= if show_optional_body do %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<.input
type="url"
name="endpoint[provider_settings][http_referer]"
label={gettext("HTTP Referer")}
value={
get_in(@form.params, ["provider_settings", "http_referer"]) ||
(@endpoint && get_in(@endpoint.provider_settings || %{}, ["http_referer"])) ||
""
}
placeholder={@site_url || "https://yourapp.com"}
/>
<p class="label-text-alt text-base-content/50 mt-1">
<%= if @site_url do %>
{gettext("Defaults to site_url from")}
<.pk_link navigate="/admin/settings" class="link link-primary">
{gettext("Settings")}
</.pk_link>. {gettext("Leave blank to inherit.")}
<% else %>
{gettext("For OpenRouter rankings. Set site_url in")}
<.pk_link navigate="/admin/settings" class="link link-primary">
{gettext("Settings")}
</.pk_link> {gettext("to use it as the default.")}
<% end %>
</p>
</div>
<div>
<.input
type="text"
name="endpoint[provider_settings][x_title]"
label={gettext("X-Title")}
value={
get_in(@form.params, ["provider_settings", "x_title"]) ||
(@endpoint && get_in(@endpoint.provider_settings || %{}, ["x_title"])) || ""
}
placeholder={@project_title || "My Application"}
/>
<p class="label-text-alt text-base-content/50 mt-1">
{gettext("Defaults to project_title (%{title}) from", title: @project_title)}
<.pk_link navigate="/admin/settings" class="link link-primary">
{gettext("Settings")}
</.pk_link>. {gettext("Leave blank to inherit.")}
</p>
</div>
</div>
<% end %>
</.form_section>
<.form_actions
cancel_to={PhoenixKitAI.Routes.ai_path()}
submit_label={
if @endpoint, do: gettext("Update Endpoint"), else: gettext("Create Endpoint")
}
/>
</.form>
</div>
</div>
<%!-- Inline hook for the manual-model-id fallback. Stamps the input's
current value onto the sibling submit button's phx-value-model
just before LiveView reads the click — replacing the previous
`onclick="..."` inline JS, which mixed imperative DOM with LV
events and tripped strict-CSP setups. --%>
<script>
if (!window.PhoenixKitHooks) window.PhoenixKitHooks = {};
window.PhoenixKitHooks.ManualModelInput = {
mounted() {
this.button = this.el
.closest('.join')
?.querySelector('[data-manual-model-submit]');
if (!this.button) return;
this.sync = () => {
this.button.setAttribute('phx-value-model', this.el.value);
};
this.el.addEventListener('input', this.sync);
this.sync();
},
destroyed() {
this.el.removeEventListener?.('input', this.sync);
}
};
// Client-side filter over the model grid. Scopes to the grid
// referenced by `data-grid-id`; toggles `display: none` on cards
// whose `data-search-text` doesn't contain the query. No LV
// round-trip per keystroke.
window.PhoenixKitHooks.ModelGridSearch = {
mounted() {
this.handler = (e) => {
const query = (e.target.value || '').toLowerCase().trim();
const grid = document.getElementById(e.target.dataset.gridId);
if (!grid) return;
grid.querySelectorAll('button[data-search-text]').forEach((card) => {
const text = card.getAttribute('data-search-text') || '';
card.style.display =
query === '' || text.indexOf(query) !== -1 ? '' : 'none';
});
};
this.el.addEventListener('input', this.handler);
},
destroyed() {
this.el.removeEventListener?.('input', this.handler);
}
};
</script>