Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 380 additions & 0 deletions dev_docs/2026-02-16-struct-vs-map-audit.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions lib/modules/ai/ai_model.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule PhoenixKit.Modules.AI.AIModel do
@moduledoc """
Struct representing a normalized AI model from the OpenRouter API.

Constructed in `OpenRouterClient.normalize_models/2` from JSON API responses.
Consumed by `EndpointForm` LiveView for model selection and configuration.

## Fields

- `id` - Model identifier (e.g., `"anthropic/claude-3-opus"`)
- `name` - Human-readable name (e.g., `"Claude 3 Opus"`)
- `description` - Model description
- `context_length` - Maximum context window size in tokens
- `max_completion_tokens` - Maximum output tokens
- `supported_parameters` - List of supported parameters (e.g., `["temperature", "top_p"]`)
- `pricing` - Pricing info map with `"prompt"` and `"completion"` keys
- `architecture` - Architecture info map with `"modality"` key
- `top_provider` - Top provider metadata map
"""

@enforce_keys [:id]
defstruct [
:id,
:name,
:description,
:context_length,
:max_completion_tokens,
supported_parameters: [],
pricing: %{},
architecture: %{},
top_provider: %{}
]

@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
description: String.t() | nil,
context_length: integer() | nil,
max_completion_tokens: integer() | nil,
supported_parameters: [String.t()],
pricing: map(),
architecture: map(),
top_provider: map()
}
end
58 changes: 44 additions & 14 deletions lib/modules/ai/openrouter_client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
{:ok, models} = PhoenixKit.Modules.AI.OpenRouterClient.fetch_models("sk-or-v1-...")
"""

alias PhoenixKit.Modules.AI.AIModel

require Logger

@base_url "https://openrouter.ai/api/v1"
Expand Down Expand Up @@ -143,7 +145,7 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
grouped =
models
|> Enum.group_by(fn model ->
case String.split(model["id"], "/") do
case String.split(model.id, "/") do
[provider | _] -> provider
_ -> "other"
end
Expand Down Expand Up @@ -348,7 +350,19 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do

Returns `{label, value}` tuple.
"""
def model_option(model) do
def model_option(%AIModel{} = model) do
label =
if model.name && model.name != "" do
provider = extract_provider(model.id)
"#{model.name} (#{provider})"
else
model.id || "Unknown"
end

{label, model.id || ""}
end

def model_option(model) when is_map(model) do
label =
case model do
%{"name" => name, "id" => id} when name != "" ->
Expand Down Expand Up @@ -441,16 +455,16 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
|> Enum.map(fn model ->
top_provider = model["top_provider"] || %{}

%{
"id" => model["id"],
"name" => model["name"] || extract_model_name(model["id"]),
"description" => model["description"],
"context_length" => model["context_length"],
"max_completion_tokens" => top_provider["max_completion_tokens"],
"supported_parameters" => model["supported_parameters"] || [],
"pricing" => normalize_pricing(model["pricing"]),
"architecture" => model["architecture"],
"top_provider" => top_provider
%AIModel{
id: model["id"],
name: model["name"] || extract_model_name(model["id"]),
description: model["description"],
context_length: model["context_length"],
max_completion_tokens: top_provider["max_completion_tokens"],
supported_parameters: model["supported_parameters"] || [],
pricing: normalize_pricing(model["pricing"]),
architecture: model["architecture"] || %{},
top_provider: top_provider
}
end)
end
Expand Down Expand Up @@ -491,7 +505,7 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
def fetch_model(api_key, model_id, opts \\ []) do
case fetch_models(api_key, opts) do
{:ok, models} ->
case Enum.find(models, fn m -> m["id"] == model_id end) do
case Enum.find(models, fn m -> m.id == model_id end) do
nil -> {:error, "Model not found"}
model -> {:ok, model}
end
Expand All @@ -512,6 +526,10 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
iex> model_supports_parameter?(model, "tools")
false
"""
def model_supports_parameter?(%AIModel{} = model, param) do
param in model.supported_parameters
end

def model_supports_parameter?(model, param) when is_map(model) do
supported = model["supported_parameters"] || []
param in supported
Expand All @@ -523,13 +541,25 @@ defmodule PhoenixKit.Modules.AI.OpenRouterClient do
Returns the model's max_completion_tokens if available,
otherwise falls back to a percentage of context_length.
"""
def get_model_max_tokens(%AIModel{} = model) do
cond do
model.max_completion_tokens ->
model.max_completion_tokens

model.context_length ->
div(model.context_length, 4)

true ->
4096
end
end

def get_model_max_tokens(model) when is_map(model) do
cond do
model["max_completion_tokens"] ->
model["max_completion_tokens"]

model["context_length"] ->
# Default to 25% of context length if max_completion_tokens not specified
div(model["context_length"], 4)

true ->
Expand Down
21 changes: 15 additions & 6 deletions lib/modules/ai/web/endpoint_form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
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
Expand Down Expand Up @@ -113,16 +114,16 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
end

defp get_max_for_field("max_tokens", _definition, selected_model) do
selected_model && selected_model["max_completion_tokens"]
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"]}"
if selected_model && selected_model.max_completion_tokens do
"Max: #{selected_model.max_completion_tokens}"
else
"Model default"
end
Expand Down Expand Up @@ -551,7 +552,7 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
# Private helpers

defp find_model(models, model_id) do
Enum.find(models, fn m -> m["id"] == model_id end)
Enum.find(models, fn m -> m.id == model_id end)
end

@doc """
Expand Down Expand Up @@ -658,11 +659,15 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
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()

# Filter to parameters we have definitions for AND model supports
supported_keys = Enum.filter(supported, &Map.has_key?(definitions, &1))
group_parameters(supported_keys, definitions)
end
Expand All @@ -681,6 +686,10 @@ defmodule PhoenixKit.Modules.AI.Web.EndpointForm do
"""
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
Expand Down
38 changes: 19 additions & 19 deletions lib/modules/ai/web/endpoint_form.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
<%!-- Model Name --%>
<div class="font-semibold text-lg">
<%= if @selected_model do %>
{@selected_model["name"] || current_model_id}
{@selected_model.name || current_model_id}
<% else %>
{current_model_id}
<% end %>
Expand All @@ -193,24 +193,24 @@
</span>

<%!-- Context Length --%>
<%= if @selected_model["context_length"] do %>
<%= if @selected_model.context_length do %>
<span class="badge badge-outline badge-sm">
<.icon name="hero-document-text" class="w-3 h-3 mr-1" />
{format_number(@selected_model["context_length"])} ctx
{format_number(@selected_model.context_length)} ctx
</span>
<% end %>

<%!-- Max Completion Tokens --%>
<%= if @selected_model["max_completion_tokens"] do %>
<%= if @selected_model.max_completion_tokens do %>
<span class="badge badge-outline badge-sm">
<.icon name="hero-pencil" class="w-3 h-3 mr-1" />
{format_number(@selected_model["max_completion_tokens"])} max output
{format_number(@selected_model.max_completion_tokens)} max output
</span>
<% end %>

<%!-- Pricing --%>
<%= if @selected_model["pricing"] do %>
<% pricing = @selected_model["pricing"] %>
<%= if @selected_model.pricing do %>
<% pricing = @selected_model.pricing %>
<%= if pricing["prompt"] do %>
<span class="badge badge-success badge-outline badge-sm">
<% prompt_price =
Expand Down Expand Up @@ -369,12 +369,12 @@

<div class="grid gap-2 max-h-80 overflow-y-auto pr-1">
<%= for model <- @provider_models do %>
<% pricing = model["pricing"] || %{} %>
<% is_selected = current_model_id == model["id"] %>
<% pricing = model.pricing || %{} %>
<% is_selected = current_model_id == model.id %>
<button
type="button"
phx-click="select_model"
phx-value-model={model["id"]}
phx-value-model={model.id}
class={[
"rounded-lg border p-3 transition-colors cursor-pointer text-left w-full",
is_selected &&
Expand All @@ -387,27 +387,27 @@
<div class="flex-1 min-w-0">
<%!-- Model Name --%>
<div class="font-semibold truncate">
{model["name"] || model["id"]}
{model.name || model.id}
</div>

<%!-- Model ID --%>
<div class="text-xs font-mono text-base-content/50 truncate">
{model["id"]}
{model.id}
</div>

<%!-- Badges --%>
<div class="flex flex-wrap gap-1 mt-2">
<%!-- Context Length --%>
<%= if model["context_length"] do %>
<%= if model.context_length do %>
<span class="badge badge-outline badge-xs">
{format_number(model["context_length"])} ctx
{format_number(model.context_length)} ctx
</span>
<% end %>

<%!-- Max Output --%>
<%= if model["max_completion_tokens"] do %>
<%= if model.max_completion_tokens do %>
<span class="badge badge-outline badge-xs">
{format_number(model["max_completion_tokens"])} out
{format_number(model.max_completion_tokens)} out
</span>
<% end %>

Expand Down Expand Up @@ -478,11 +478,11 @@
<.icon name="hero-information-circle" class="w-4 h-4" />
<span class="text-sm">
Context: {PhoenixKit.Modules.AI.Web.EndpointForm.format_number(
@selected_model["context_length"]
@selected_model.context_length
)} tokens
<%= if @selected_model["max_completion_tokens"] do %>
<%= if @selected_model.max_completion_tokens do %>
• Max output: {PhoenixKit.Modules.AI.Web.EndpointForm.format_number(
@selected_model["max_completion_tokens"]
@selected_model.max_completion_tokens
)} tokens
<% end %>
</span>
Expand Down
6 changes: 3 additions & 3 deletions lib/modules/billing/billing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3087,12 +3087,12 @@ defmodule PhoenixKit.Modules.Billing do
# Update invoice with checkout session info
invoice
|> Ecto.Changeset.change(%{
checkout_session_id: session[:session_id],
checkout_url: session[:url]
checkout_session_id: session.id,
checkout_url: session.url
})
|> repo().update()

{:ok, session[:url]}
{:ok, session.url}

{:error, reason} ->
{:error, reason}
Expand Down
Loading
Loading