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
14 changes: 14 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,17 @@ config :junit_formatter,
config :trento,
:flaky_tests_detection,
enabled?: System.get_env("WRITE_JUNIT") == "1"

config :trento, :ai,
providers: [
provider1: [
models: [
"model1"
]
],
provider2: [
models: [
"model1"
]
]
]
16 changes: 16 additions & 0 deletions lib/trento/ai/llm_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,27 @@ defmodule Trento.AI.LLMRegistry do
end)
end

@doc """
Checks if a given model is supported by a specific provider.
"""
@spec model_supported_by_provider?(bitstring(), atom()) :: boolean()
def model_supported_by_provider?(model, provider) do
model in get_provider_models(provider)
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)

@doc """
Checks if a given provider is supported.
"""
@spec provider_supported?(atom()) :: boolean()
def provider_supported?(provider) when is_atom(provider), do: provider in providers()

def provider_supported?(_), do: false

defp get_ai_providers_config, do: Keyword.get(ApplicationConfigLoader.load(), :providers, [])
end
51 changes: 38 additions & 13 deletions lib/trento/ai/user_configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule Trento.AI.UserConfiguration do
schema "ai_configurations" do
field :model, :string
field :provider, Ecto.Enum, values: LLMRegistry.providers()
# field :provider, :string
field :api_key, EncryptedBinary, redact: true

belongs_to :user, Trento.Users.User, primary_key: true
Expand All @@ -22,29 +23,53 @@ defmodule Trento.AI.UserConfiguration do
end

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)
|> cast(attrs, [:user_id, :model, :provider, :api_key])
|> validate_required([:user_id, :model, :provider, :api_key])
|> validate_change(:provider, &validate_provider/2)
|> validate_model()
|> 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
defp validate_provider(_provider_field_atom, provider) do
if LLMRegistry.provider_supported?(provider) do
[]
else
[model: {"is not supported", validation: :ai_model_validity}]
[provider: {"is not supported", validation: :ai_provider_validity}]
end
end

defp validate_model(%{errors: [provider: _]} = changeset), do: changeset

defp validate_model(changeset) do
provider = get_field(changeset, :provider)
model = get_field(changeset, :model)

changeset
|> force_change(:model, model)
|> force_change(:provider, provider)
|> validate_change(:model, fn _model_atom, _model ->
model_supported? = LLMRegistry.model_supported?(model)
model_supported_by_provider? = LLMRegistry.model_supported_by_provider?(model, provider)

case {model_supported?, model_supported_by_provider?} do
{true, true} ->
[]

{true, false} ->
[
model:
{"is not supported by the specified provider",
validation: :ai_model_provider_mismatch}
]

{false, _} ->
[model: {"is not supported", validation: :ai_model_validity}]
end
end)
end
end
75 changes: 75 additions & 0 deletions lib/trento_web/controllers/v1/ai_configuration_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule TrentoWeb.V1.AIConfigurationController do
use TrentoWeb, :controller
use OpenApiSpex.ControllerSpecs

alias Trento.Users.User

alias Trento.AI

alias Trento.AI.UserConfiguration

alias TrentoWeb.OpenApi.V1.Schema

import Plug.Conn

plug TrentoWeb.Plugs.LoadUserPlug

plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true
action_fallback TrentoWeb.FallbackController

operation :create_ai_configuration,
summary: "Creates User's AI Configuration",
description: "Creates a new AI configuration for the currently authenticated user.",
tags: ["Profile"],
request_body:
{"CreateUserAIConfiguration", "application/json", Schema.AI.CreateUserConfigurationRequest},
responses: [
created:
{"User AI Configuration created successfully.", "application/json",
Schema.AI.UserConfiguration},
unprocessable_entity: Schema.UnprocessableEntity.response(),
unauthorized: Schema.Unauthorized.response(),
forbidden: Schema.Forbidden.response()
]

def create_ai_configuration(conn, _) do
%User{} = current_user = Pow.Plug.current_user(conn)

creation_params = OpenApiSpex.body_params(conn)

with {:ok, %UserConfiguration{} = user_ai_config} <-
AI.create_user_configuration(current_user, creation_params) do
conn
|> put_status(:created)
|> render(:ai_configuration, %{ai_configuration: user_ai_config})
end
end

operation :update_ai_configuration,
summary: "Updates User's AI Configuration",
description: "Updates the AI configuration for the currently authenticated user.",
tags: ["Profile"],
request_body:
{"UpdateUserAIConfiguration", "application/json", Schema.AI.UpdateUserConfigurationRequest},
responses: [
ok:
{"User AI Configuration updated successfully.", "application/json",
Schema.AI.UserConfiguration},
unprocessable_entity: Schema.UnprocessableEntity.response(),
unauthorized: Schema.Unauthorized.response(),
forbidden: Schema.Forbidden.response()
]

def update_ai_configuration(conn, _) do
%User{} = current_user = Pow.Plug.current_user(conn)

update_params = OpenApiSpex.body_params(conn)

with {:ok, %UserConfiguration{} = user_ai_config} <-
AI.update_user_configuration(current_user, update_params) do
conn
|> put_status(:ok)
|> render(:ai_configuration, %{ai_configuration: user_ai_config})
end
end
end
15 changes: 15 additions & 0 deletions lib/trento_web/controllers/v1/ai_configuration_json.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule TrentoWeb.V1.AIConfigurationJSON do
def ai_configuration(%{ai_configuration: ai_configuration}),
do: ai_configuration_entry(ai_configuration)

def ai_configuration_entry(%{
provider: provider,
model: model
}),
do: %{
provider: provider,
model: model
}

def ai_configuration_entry(_), do: nil
end
19 changes: 6 additions & 13 deletions lib/trento_web/controllers/v1/profile_json.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
defmodule TrentoWeb.V1.ProfileJSON do
alias TrentoWeb.V1.{AbilityJSON, PersonalAccessTokensJSON}
alias TrentoWeb.V1.{
AbilityJSON,
AIConfigurationJSON,
PersonalAccessTokensJSON
}

def profile(%{
user: %{
Expand Down Expand Up @@ -32,7 +36,7 @@ defmodule TrentoWeb.V1.ProfileJSON do
created_at: created_at,
analytics_enabled: analytics_enabled_at != nil,
analytics_eula_accepted: analytics_eula_accepted_at != nil,
ai_configuration: ai_configuration(ai_configuration),
ai_configuration: AIConfigurationJSON.ai_configuration_entry(ai_configuration),
idp_user: length(user_identities) > 0,
updated_at: updated_at
}
Expand All @@ -49,15 +53,4 @@ defmodule TrentoWeb.V1.ProfileJSON do
}
}),
do: %{secret: Base.encode32(secret, padding: false), secret_qr_encoded: secret_qr_encoded}

defp ai_configuration(%{
provider: provider,
model: model
}),
do: %{
provider: provider,
model: model
}

defp ai_configuration(_), do: nil
end
119 changes: 119 additions & 0 deletions lib/trento_web/openapi/v1/schema/ai.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
defmodule TrentoWeb.OpenApi.V1.Schema.AI do
@moduledoc false

require OpenApiSpex
alias OpenApiSpex.Schema

defmodule UserConfiguration do
@moduledoc false

OpenApiSpex.schema(
%{
title: "AIUserConfigurationV1",
description: "AI configuration for a user.",
type: :object,
nullable: true,
additionalProperties: false,
properties: %{
provider: %Schema{
type: :string,
description: "Chosen AI provider.",
example: "googleai",
nullable: false
},
model: %Schema{
type: :string,
description: "Chosen AI model.",
example: "gemini-2.0-flash",
nullable: false
}
},
example: %{
provider: "googleai",
model: "gemini-2.0-flash"
},
required: [:provider, :model]
},
struct?: false
)
end

defmodule CreateUserConfigurationRequest do
@moduledoc false

OpenApiSpex.schema(
%{
title: "CreateUserConfigurationRequestV1",
description: "AI configuration request for a user.",
type: :object,
additionalProperties: false,
properties: %{
provider: %Schema{
type: :string,
description: "AI provider.",
nullable: false,
example: "googleai"
},
model: %Schema{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question:
Don't we need the AI provider field here?
The same for the update.
Or do you extract the provide in the backend somehow?
Is it possible to have the same model name for multiple providers at any given time in the future?

Copy link
Copy Markdown
Member Author

@nelsonkopliku nelsonkopliku Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to extract the provider based on the model. See here

Is it possible to have the same model name for multiple providers at any given time in the future?

Good question. Technically it might be possible, although it's not in the immediate scope.

Anyway, in the current scheme of things where the provider/model map is defined as follows

config :trento, :ai,
  enabled: true,
  providers: [
    googleai: [
      models: [
        "gemini-2.5-pro",
        "gemini-2.5-flash",
        "gemini-2.5-flash-lite",
        "gemini-3.1-flash-preview",
        "gemini-3.1-flash-lite-preview",
        "gemini-3.1-pro-preview"
      ]
    ],
    openai: [
      models: [
        "o3-mini",
        "o3",
        "gpt-4.1",
        "gpt-4",
        "gpt-5-mini",
        "gpt-5.4"
      ]
    ],
    anthropic: [
      models: [
        "claude-opus-4-6",
        "claude-sonnet-4-6",
        "claude-haiku-4-5"
      ]
    ]
  ]

we could have the same model in many providers by for instace prepending the provider to the model name openai/modelname and googleai/modelname

This could be either at config level or extracted when provided as a request parameter.

I think we can extend when the need arises.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I made provider to be explicitly set upon creation and update in this commit 700aa78.

It was a bit more tedious than I hoped and made the PR changes rise by more than 50% 🙈 (it is fair to say that it is mainly tests, tho)

Now, in total honesty I found the previous version simpler, and it was easily extendable as well to support the same model over many providers scenario by changing the pattern of the model name (the openai/modelname and googleai/modelname option).

However, in any case it is very likely that things around this topic will change because we aim to be able to get the model list from providers' APIs, but we're not sure it will be in the very first version.

If you ask me, I would revert the last commit 700aa78 and go ahead from there, but I want to hear your opinion as well.

type: :string,
description: "AI model.",
nullable: false,
example: "gemini-2.0-flash"
},
api_key: %Schema{
type: :string,
description: "AI API key.",
nullable: false,
example: "AIza..."
}
},
example: %{
provider: "googleai",
model: "gemini-2.0-flash",
api_key: "AIza..."
},
required: [:provider, :model, :api_key]
},
struct?: false
)
end

defmodule UpdateUserConfigurationRequest do
@moduledoc false

OpenApiSpex.schema(
%{
title: "UpdateUserConfigurationRequestV1",
description: "AI configuration request for a user.",
type: :object,
additionalProperties: false,
minProperties: 1,
properties: %{
provider: %Schema{
type: :string,
description: "AI provider.",
nullable: false,
example: "googleai"
},
model: %Schema{
type: :string,
description: "AI model.",
nullable: false,
example: "gemini-2.0-flash"
},
api_key: %Schema{
type: :string,
description: "AI API key.",
nullable: false,
example: "AIza..."
}
},
example: %{
api_key: "AIza...",
model: "gemini-2.0-flash"
}
},
struct?: false
)
end
end
Loading
Loading