Skip to content
Open
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
26 changes: 9 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Bodhi supports multiple AI providers that can be switched via configuration.

#### OpenRouter (Default)
- **Module:** `Bodhi.OpenRouter`
- **Default Model:** `deepseek/deepseek-r1-0528:free`
- **Default Model:** `openrouter/free` (when no model is selected in admin)
- **Environment Variable:** `OPENROUTER_API_KEY`
- **Website:** https://openrouter.ai/

Expand All @@ -33,10 +33,10 @@ To switch AI providers, update `config/config.exs`:

```elixir
# Use OpenRouter (default)
config :bodhi, :ai_client, Bodhi.OpenRouter
config :bodhi, :llm_provider, Bodhi.OpenRouter

# Use Google Gemini
config :bodhi, :ai_client, Bodhi.Gemini
config :bodhi, :llm_provider, Bodhi.Gemini
```

### Setting Up API Keys
Expand All @@ -51,21 +51,13 @@ config :bodhi, :ai_client, Bodhi.Gemini

3. Reload environment: `direnv allow` (if using direnv)

### Changing OpenRouter Model
### Selecting an OpenRouter Model

Edit `lib/bodhi/open_router.ex` and modify the `@default_model` attribute:

```elixir
@default_model "deepseek/deepseek-r1-0528:free" # Current default

# Other popular models:
# @default_model "anthropic/claude-3.5-sonnet"
# @default_model "openai/gpt-4-turbo"
# @default_model "meta-llama/llama-3.1-70b-instruct"
# @default_model "google/gemini-pro-1.5"
```

See all available models at: https://openrouter.ai/models
In the admin UI, open **LLM Configurations**, choose **Sync Models** to
load available OpenRouter models, then edit a model to set optional
temperature and max token values. Choose **Use this model** to select one
model for the bot. Choose **Use default model** to clear the selection and
return to `openrouter/free`.

## Features

Expand Down
72 changes: 57 additions & 15 deletions lib/bodhi/llm_configs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ defmodule Bodhi.LlmConfigs do
import Ecto.Query, warn: false

alias Bodhi.Cache
alias Bodhi.Repo
alias Bodhi.LlmConfigs.LlmConfig
alias Bodhi.Repo

@cache_key :active_llm_configs

Expand Down Expand Up @@ -45,7 +45,7 @@ defmodule Bodhi.LlmConfigs do
def get_llm_config!(id), do: Repo.get!(LlmConfig, id)

@doc """
Returns all active configs ordered by position.
Returns the selected model as a list of zero or one records.
"""
@spec get_active_configs() :: [LlmConfig.t()]
def get_active_configs do
Expand All @@ -55,12 +55,62 @@ defmodule Bodhi.LlmConfigs do
|> Repo.all()
end

@doc "Selects a model and clears any previous selection."
@spec select_llm_config(non_neg_integer()) ::
{:ok, LlmConfig.t()} | {:error, Ecto.Changeset.t()}
def select_llm_config(id) do
result = Repo.transaction(fn -> select_config_in_transaction(id) end)

case result do
{:ok, config} ->
invalidate_cache()
{:ok, config}

{:error, changeset} ->
{:error, changeset}
end
end

defp select_config_in_transaction(id) do
Repo.update_all(LlmConfig, set: [active: false])

case Repo.get(LlmConfig, id) do
nil ->
Repo.rollback(
LlmConfig.changeset(%LlmConfig{}, %{})
|> Ecto.Changeset.add_error(:id, "model does not exist")
)

config ->
config
|> Ecto.Changeset.change(active: true)
|> Ecto.Changeset.unique_constraint(
:active,
name: :llm_configs_one_active_index
)
|> Repo.update()
|> rollback_on_error()
end
end

defp rollback_on_error({:ok, config}), do: config
defp rollback_on_error({:error, changeset}), do: Repo.rollback(changeset)

@doc "Clears the selected model so OpenRouter uses its default."
@spec clear_llm_config_selection() :: :ok
def clear_llm_config_selection do
Repo.update_all(LlmConfig, set: [active: false])
invalidate_cache()
end

@doc """
Creates an LLM config.
"""
@spec create_llm_config(map()) ::
{:ok, LlmConfig.t()} | {:error, Ecto.Changeset.t()}
def create_llm_config(attrs) do
attrs = Map.drop(attrs, [:active, "active"])

result =
%LlmConfig{}
|> LlmConfig.changeset(attrs)
Expand All @@ -76,13 +126,15 @@ defmodule Bodhi.LlmConfigs do
@spec update_llm_config(LlmConfig.t(), map()) ::
{:ok, LlmConfig.t()} | {:error, Ecto.Changeset.t()}
def update_llm_config(%LlmConfig{} = config, attrs) do
attrs = Map.drop(attrs, [:active, "active"])

result =
config
|> LlmConfig.changeset(attrs)
|> Repo.update()

with {:ok, _} <- result do
if config.active || touches_active?(attrs) do
if config.active do
invalidate_cache()
end
end
Expand Down Expand Up @@ -251,16 +303,6 @@ defmodule Bodhi.LlmConfigs do
ArgumentError -> :position
end

defp maybe_invalidate_cache({:ok, _}, attrs) do
if touches_active?(attrs), do: invalidate_cache()
end

defp maybe_invalidate_cache(_, _), do: :ok

defp touches_active?(attrs) do
active =
Map.get(attrs, :active) || Map.get(attrs, "active")

active == true
end
defp maybe_invalidate_cache({:ok, _}, _attrs), do: :ok
defp maybe_invalidate_cache({:error, _}, _attrs), do: :ok
end
9 changes: 3 additions & 6 deletions lib/bodhi/llm_configs/llm_config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ defmodule Bodhi.LlmConfigs.LlmConfig do
@moduledoc """
Schema for LLM model configurations.

Each config represents a model that can be used via
OpenRouter. Multiple configs can be active simultaneously
-- their `position` determines fallback order (lower =
higher priority). Active configs are sent as OpenRouter's
`models` array with `route: "fallback"`.
Each config represents an OpenRouter model. At most one
configuration can be active at a time.
"""

use Ecto.Schema
import Ecto.Changeset

@allowed_fields ~w(
name model position temperature max_tokens active
name model position temperature max_tokens
)a

@required_fields ~w(name model position)a
Expand Down
28 changes: 9 additions & 19 deletions lib/bodhi/open_router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ defmodule Bodhi.OpenRouter do
@moduledoc """
OpenRouter API wrapper.

Provides unified access to multiple AI models through
OpenAI-compatible API. Supports database-backed model
configuration with multi-model fallback via OpenRouter's
`models` array and `route: "fallback"`.
Provides access to a selected OpenRouter model through its
OpenAI-compatible API, with `openrouter/free` as the default.
"""
@behaviour Bodhi.Behaviours.LLMProvider

Expand Down Expand Up @@ -70,7 +68,7 @@ defmodule Bodhi.OpenRouter do
Returns active LLM configs from cache or database.

Results are cached for 5 minutes. Returns an empty list
when no active configs exist (falls back to default model).
when no model is selected (falls back to the default model).
"""
@spec resolve_config() :: [LlmConfig.t()]
def resolve_config do
Expand All @@ -88,11 +86,8 @@ defmodule Bodhi.OpenRouter do
@doc """
Builds the JSON request body for OpenRouter.

- Single active model: uses `model` key
- Multiple active models: uses `models` array +
`route: "fallback"`
- No DB config: uses `@default_model`
- Temperature/max_tokens from highest-priority config
Uses the selected model and its optional parameters, or the
default model when no configuration is selected.
"""
@spec build_body([map()], String.t(), [LlmConfig.t()]) ::
String.t()
Expand Down Expand Up @@ -140,17 +135,12 @@ defmodule Bodhi.OpenRouter do
Map.put(body, :model, @default_model)
end

defp put_model_config(body, [single]) do
Map.put(body, :model, single.model)
defp put_model_config(body, [selected | _]) do
Map.put(body, :model, selected.model)
end

defp put_model_config(body, configs) do
models = Enum.map(configs, & &1.model)

body
|> Map.put(:models, models)
|> Map.put(:route, "fallback")
end
defp put_model_config(body, _configs),
do: Map.put(body, :model, @default_model)

defp put_optional_params(body, []) do
body
Expand Down
2 changes: 1 addition & 1 deletion lib/bodhi_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ defmodule BodhiWeb do
import BodhiWeb.Components.Seo

# Common modules used in templates
alias Phoenix.LiveView.JS
alias BodhiWeb.Layouts
alias Phoenix.LiveView.JS

# Routes generation with the ~p sigil
unquote(verified_routes())
Expand Down
8 changes: 3 additions & 5 deletions lib/bodhi_web/live/llm_config_live/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,9 @@ defmodule BodhiWeb.LlmConfigLive.Form do
type="number"
label="Max Tokens"
/>
<.input
field={@form[:active]}
type="checkbox"
label="Active"
/>
<p class="my-4 text-sm text-zinc-500">
Save this model, then select it from the model list.
</p>
<footer>
<.button phx-disable-with="Saving..." variant="primary">
Save Config
Expand Down
68 changes: 55 additions & 13 deletions lib/bodhi_web/live/llm_config_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ defmodule BodhiWeb.LlmConfigLive.Index do
</:actions>
</.header>

<section id="current-model" class="my-4 rounded-lg border p-4">
<h2 class="font-semibold">Current model</h2>
<p :if={@selected_model}>
{@selected_model.name} ({@selected_model.model})
</p>
<p :if={!@selected_model}>Default model: openrouter/free</p>
<button
:if={@selected_model}
id="clear-model-selection"
type="button"
phx-click="clear_selection"
class="underline"
>Use default model</button>
</section>

<div class="flex items-center gap-4 my-4">
<form
id="filter-form"
Expand Down Expand Up @@ -161,7 +176,7 @@ defmodule BodhiWeb.LlmConfigLive.Index do
)
]}>
{if cfg.active,
do: "Active",
do: "Selected",
else: "Inactive"}
</span>
</div>
Expand All @@ -174,12 +189,12 @@ defmodule BodhiWeb.LlmConfigLive.Index do
Edit
</.link>
<.link
phx-click="toggle_active"
:if={!cfg.active}
id={"select-model-#{cfg.id}"}
phx-click="select_model"
phx-value-id={cfg.id}
>
{if cfg.active,
do: "Deactivate",
else: "Activate"}
Use this model
</.link>
<.link
phx-click={
Expand Down Expand Up @@ -241,6 +256,7 @@ defmodule BodhiWeb.LlmConfigLive.Index do
|> assign(:page, %{title: "LLM Configurations"})
|> assign(:syncing, false)
|> assign(:params, @default_params)
|> assign(:selected_model, List.first(LlmConfigs.get_active_configs()))
|> apply_filters()}
end

Expand Down Expand Up @@ -318,18 +334,44 @@ defmodule BodhiWeb.LlmConfigLive.Index do
config = LlmConfigs.get_llm_config!(id)
{:ok, _} = LlmConfigs.delete_llm_config(config)

{:noreply, apply_filters(socket)}
selected_model =
if socket.assigns.selected_model &&
socket.assigns.selected_model.id == config.id do
nil
else
socket.assigns.selected_model
end

{:noreply,
socket
|> assign(:selected_model, selected_model)
|> apply_filters()}
end

def handle_event("toggle_active", %{"id" => id}, socket) do
config = LlmConfigs.get_llm_config!(id)
def handle_event("select_model", %{"id" => id}, socket) do
case LlmConfigs.select_llm_config(String.to_integer(id)) do
{:ok, config} ->
{:noreply,
socket
|> assign(:selected_model, config)
|> apply_filters()
|> put_flash(:info, "#{config.name} selected")}

{:ok, _updated} =
LlmConfigs.update_llm_config(config, %{
active: !config.active
})
{:error, changeset} ->
{:noreply,
put_flash(socket, :error, "Could not select model: #{inspect(changeset.errors)}")}
end
end

{:noreply, apply_filters(socket)}
def handle_event("clear_selection", _params, socket) do
case LlmConfigs.clear_llm_config_selection() do
:ok ->
{:noreply,
socket
|> assign(:selected_model, nil)
|> apply_filters()
|> put_flash(:info, "Using the default model")}
end
end

defp apply_filters(socket) do
Expand Down
Loading
Loading