-
Notifications
You must be signed in to change notification settings - Fork 19
Basic AI config functions #4140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
01a5b6a
99d17b0
73a6bb5
ed5d50c
cae1776
1d2f6a9
50d20f2
ecb3839
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| defmodule Trento.AI do | ||
| @moduledoc """ | ||
| The `Trento.AI` module provides functions to interact with the AI features of the Trento application. | ||
| """ | ||
|
|
||
| alias Trento.AI.ApplicationConfigLoader | ||
|
|
||
| alias Trento.AI.Configurations | ||
|
|
||
| @doc """ | ||
| Checks if the AI features are enabled. | ||
| """ | ||
| @spec enabled?() :: boolean() | ||
| def enabled?, | ||
| do: Keyword.get(ApplicationConfigLoader.load(), :enabled, false) | ||
|
|
||
| @doc """ | ||
| Creates a user configuration for AI. | ||
|
|
||
| See `Trento.AI.Configurations.create_user_configuration/2` for more details. | ||
| """ | ||
| def create_user_configuration(user, attrs), | ||
| do: configurations().create_user_configuration(user, attrs) | ||
|
|
||
| @doc """ | ||
| Updates a user configuration for AI. | ||
|
|
||
| See `Trento.AI.Configurations.update_user_configuration/2` for more details. | ||
| """ | ||
| def update_user_configuration(user, attrs), | ||
| do: configurations().update_user_configuration(user, attrs) | ||
|
|
||
| defp configurations, | ||
| do: Keyword.get(ApplicationConfigLoader.load(), :configurations, Configurations) | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| defmodule Trento.AI.ApplicationConfigLoader do | ||
| @moduledoc """ | ||
| This module is responsible for loading the AI application configuration and providing access to it. | ||
| """ | ||
|
|
||
| @behaviour Trento.AI.ApplicationConfigLoader | ||
|
|
||
| @callback load_config :: keyword() | ||
|
|
||
| @impl true | ||
| def load_config, do: Application.get_env(:trento, :ai, []) | ||
|
|
||
| @spec load :: keyword() | ||
| def load, do: impl().load_config() | ||
|
|
||
| defp impl, | ||
| do: | ||
| :trento | ||
| |> Application.get_env(:ai, []) | ||
| |> Keyword.get(:application_config_loader, __MODULE__) | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| defmodule Trento.AI.Configurations do | ||
| @moduledoc """ | ||
| This module is responsible for managing user AI configurations. | ||
| """ | ||
|
|
||
| alias Trento.Users.User | ||
|
|
||
| alias Trento.AI.UserConfiguration | ||
|
|
||
| alias Trento.Repo | ||
|
|
||
| @doc """ | ||
| Creates a user configuration for AI. | ||
|
|
||
| Only eligible users (not deleted or locked) can have an AI configuration. | ||
| """ | ||
| @spec create_user_configuration(User.t(), map()) :: | ||
| {:ok, UserConfiguration.t()} | {:error, Ecto.Changeset.t()} | {:error, :forbidden} | ||
| def create_user_configuration( | ||
| %User{id: user_id, deleted_at: nil, locked_at: nil}, | ||
| attrs | ||
| ) do | ||
| %UserConfiguration{} | ||
| |> UserConfiguration.changeset(Map.put(attrs, :user_id, user_id)) | ||
| |> Repo.insert() | ||
| end | ||
|
|
||
| def create_user_configuration(%User{}, _), do: {:error, :forbidden} | ||
|
|
||
| @doc """ | ||
| Updates a user configuration for AI. | ||
|
|
||
| Only eligible users (not deleted or locked) can update their AI configuration. | ||
|
|
||
| If the user does not have an existing configuration, an error will be returned. | ||
| """ | ||
| @spec update_user_configuration(User.t(), map()) :: | ||
| {:ok, UserConfiguration.t()} | ||
| | {:error, Ecto.Changeset.t()} | ||
| | {:error, :forbidden | :not_found} | ||
| def update_user_configuration( | ||
| %User{id: user_id, deleted_at: nil, locked_at: nil}, | ||
| attrs | ||
| ) | ||
| when not is_nil(user_id) do | ||
| case Repo.get_by(UserConfiguration, user_id: user_id) do | ||
| nil -> | ||
| {:error, :not_found} | ||
|
|
||
| %UserConfiguration{} = user_configuration -> | ||
| user_configuration | ||
| |> UserConfiguration.changeset(attrs) | ||
| |> Repo.update() | ||
| end | ||
| end | ||
|
|
||
| def update_user_configuration(%User{}, _), do: {:error, :forbidden} | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| defmodule Trento.AI.LLMRegistry do | ||
| @moduledoc """ | ||
| This module is responsible for managing the registry of available LLM providers and their models. | ||
| """ | ||
|
|
||
| alias Trento.AI.ApplicationConfigLoader | ||
|
|
||
| @doc """ | ||
| Returns the list of configured LLM providers. | ||
| """ | ||
| @spec providers :: [atom()] | ||
| def providers, do: Keyword.keys(get_ai_providers_config()) | ||
|
|
||
| @doc """ | ||
| Returns the list of models for a given provider or all models if `:all` is passed. | ||
| """ | ||
| @spec get_provider_models(atom() | :all) :: [bitstring()] | ||
| def get_provider_models(:all), | ||
| do: | ||
| Enum.flat_map(get_ai_providers_config(), fn {_provider, config} -> | ||
| Keyword.get(config, :models, []) | ||
| end) | ||
|
|
||
| def get_provider_models(provider) when is_atom(provider) do | ||
| get_ai_providers_config() | ||
| |> Keyword.get(provider, []) | ||
| |> Keyword.get(:models, []) | ||
| end | ||
|
|
||
| def get_provider_models(_), do: [] | ||
|
|
||
| @doc """ | ||
| Returns the provider for a given model or nil if the model is not supported. | ||
| """ | ||
| @spec get_model_provider(bitstring()) :: atom() | nil | ||
| def get_model_provider(model) do | ||
| Enum.find_value(get_ai_providers_config(), fn {provider, config} -> | ||
| if model in Keyword.get(config, :models, []) do | ||
| provider | ||
| else | ||
| nil | ||
| end | ||
| end) | ||
| end | ||
|
|
||
| @doc """ | ||
| Checks if a given model is supported by any provider. | ||
| """ | ||
| @spec model_supported?(bitstring()) :: boolean() | ||
| def model_supported?(model), do: model in get_provider_models(:all) | ||
|
|
||
| defp get_ai_providers_config, do: Keyword.get(ApplicationConfigLoader.load(), :providers, []) | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| defmodule Trento.AI.UserConfiguration do | ||
| @moduledoc false | ||
|
|
||
| use Ecto.Schema | ||
| import Ecto.Changeset | ||
|
|
||
| alias Trento.Support.Ecto.EncryptedBinary | ||
|
|
||
| alias Trento.AI.LLMRegistry | ||
|
|
||
| @type t :: %__MODULE__{} | ||
|
|
||
| @primary_key false | ||
| schema "ai_configurations" do | ||
| field :model, :string | ||
| field :provider, Ecto.Enum, values: LLMRegistry.providers() | ||
| field :api_key, EncryptedBinary, redact: true | ||
|
|
||
| belongs_to :user, Trento.Users.User, primary_key: true | ||
|
|
||
| timestamps(type: :utc_datetime_usec) | ||
| end | ||
nelsonkopliku marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def changeset(ai_configuration, attrs) do | ||
| updated_attrs = maybe_update_provider(attrs) | ||
|
|
||
| ai_configuration | ||
| |> cast(updated_attrs, [:user_id, :model, :provider, :api_key]) | ||
| |> validate_required([:user_id, :model, :api_key]) | ||
| |> validate_change(:model, &validate_model/2) | ||
| |> unique_constraint(:user_id, | ||
| name: :ai_configurations_pkey, | ||
| message: "User already has a configuration" | ||
| ) | ||
| |> foreign_key_constraint(:user_id, message: "User does not exist") | ||
| end | ||
|
|
||
| defp maybe_update_provider(%{model: model} = attrs), | ||
| do: Map.put(attrs, :provider, LLMRegistry.get_model_provider(model)) | ||
|
|
||
| defp maybe_update_provider(attrs), do: attrs | ||
|
|
||
| defp validate_model(_model_field_atom, model) do | ||
| if LLMRegistry.model_supported?(model) do | ||
| [] | ||
| else | ||
| [model: {"is not supported", validation: :ai_model_validity}] | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| defmodule Trento.Repo.Migrations.AddAiUserConfigurationsTable do | ||
| use Ecto.Migration | ||
|
|
||
| def change do | ||
| create table(:ai_configurations, primary_key: false) do | ||
| add :user_id, references(:users, on_delete: :delete_all), primary_key: true | ||
| add :provider, :string | ||
| add :model, :string | ||
| add :api_key, :binary | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: As far as I understand, we are storing unencrypted user's api keys in the application database, are we? If so, I think we should rethink this approach.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should be covered here by field :api_key, EncryptedBinary, redact: trueLet me know if you find something I missed. |
||
|
|
||
| timestamps(type: :utc_datetime_usec) | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| defmodule Trento.AI.AICase do | ||
| @moduledoc false | ||
|
|
||
| use ExUnit.CaseTemplate | ||
|
|
||
| setup _ do | ||
| stub_config_loader() | ||
|
|
||
| :ok | ||
| end | ||
|
|
||
| def stub_config_loader do | ||
| Mox.stub_with( | ||
| Trento.AI.ApplicationConfigLoader.Mock, | ||
| Trento.AI.ApplicationConfigLoader | ||
| ) | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The flow overall is:
If AI is enabled system wide, it can be enabled on a per user basis; what is not clear to me is how to disable it once enabled at the user level. I wonder if we also need an AI configuration
enabled_at(either a ts or nil) or similar field?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed privately: yes there is a missing piece in the picture, that is "once I have enrolled for AI, how can I opt out?". Meaning that we might need to consider a user action to either:
Will raise it with @abravosuse and @jagabomb