Files
bDS2/lib/bds/ai/catalog.ex
2026-05-06 19:33:54 +02:00

331 lines
11 KiB
Elixir

defmodule BDS.AI.Catalog do
@moduledoc false
import Ecto.Query
import BDS.AI.SettingsStore,
only: [
get_setting: 1,
put_setting: 2,
get_catalog_meta_value: 1,
put_catalog_meta: 2
]
alias BDS.AI.CatalogProvider
alias BDS.AI.Model
alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Repo
@catalog_url "https://models.dev/api.json"
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client =
Keyword.get(
opts,
:http_client,
Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)
)
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
end
@spec refresh_model_catalog(keyword()) ::
{:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}}
| {:error, term()}
def refresh_model_catalog(opts \\ []) when is_list(opts) do
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
headers =
%{"accept" => "application/json"}
|> maybe_put_header("if-none-match", get_catalog_meta_value("etag"))
with {:ok, response} <- http_get(http_client, @catalog_url, headers) do
case response.status do
304 ->
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
{:ok, %{success: true, models_updated: 0, not_modified: true}}
200 ->
payload = Jason.decode!(response.body)
models_updated = persist_catalog(payload)
if etag = response.headers["etag"] do
:ok = put_catalog_meta("etag", etag)
end
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
{:ok, %{success: true, models_updated: models_updated, not_modified: false}}
status ->
{:error, %{kind: :http_error, status: status}}
end
end
end
@spec list_catalog_providers() :: [map()]
def list_catalog_providers do
Repo.all(from(provider in CatalogProvider, order_by: [asc: provider.id]))
|> Enum.map(fn provider ->
%{
id: provider.id,
name: provider.name,
env_keys: decode_json_list(provider.env_keys),
package_ref: provider.package_ref,
api_url: provider.api_url,
doc_url: provider.doc_url,
updated_at: provider.updated_at
}
end)
end
@spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found}
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
query =
from(model in Model,
where: model.model_id == ^model_id,
order_by: [asc: model.provider]
)
query =
case provider_id do
nil -> query
provider -> from(model in query, where: model.provider == ^provider)
end
case Repo.one(query) do
nil -> {:error, :not_found}
model -> {:ok, format_model(model)}
end
end
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
def catalog_meta(key) when is_binary(key) do
{:ok, get_catalog_meta_value(key)}
end
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)),
disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning))
}
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
end
@spec format_model(map()) :: map()
def format_model(model) do
modalities =
Repo.all(
from(modality in ModelModality,
where: modality.provider == ^model.provider and modality.model_id == ^model.model_id
)
)
%{
provider: model.provider,
model_id: model.model_id,
name: model.name,
family: model.family,
supports_attachment: model.supports_attachment,
supports_reasoning: model.supports_reasoning,
supports_tool_calls: model.supports_tool_calls,
supports_structured_output: model.supports_structured_output,
supports_temperature: model.supports_temperature,
knowledge: model.knowledge,
release_date: model.release_date,
last_updated_date: model.last_updated_date,
open_weights: model.open_weights,
input_price: model.input_price,
output_price: model.output_price,
cache_read_price: model.cache_read_price,
cache_write_price: model.cache_write_price,
context_window: model.context_window,
max_input_tokens: model.max_input_tokens,
max_output_tokens: model.max_output_tokens,
interleaved: model.interleaved,
status: model.status,
updated_at: model.updated_at,
input_modalities:
modalities
|> Enum.filter(&(&1.direction == :input))
|> Enum.map(&Atom.to_string(&1.modality)),
output_modalities:
modalities
|> Enum.filter(&(&1.direction == :output))
|> Enum.map(&Atom.to_string(&1.modality))
}
end
@spec model_capabilities(String.t()) :: %{
supports_attachment: boolean(),
supports_tool_calls: boolean(),
disables_reasoning: boolean()
}
def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id)
from_catalog =
case get_catalog_model(model_id) do
{:ok, model} ->
%{
supports_attachment: model.supports_attachment or "image" in model.input_modalities,
supports_tool_calls: model.supports_tool_calls,
disables_reasoning: false
}
_other ->
inferred_model_capabilities(model_id)
end
Map.merge(from_catalog, overrides)
end
@spec decode_nullable_json(nil | binary()) :: any()
def decode_nullable_json(nil), do: nil
def decode_nullable_json(value) when is_binary(value), do: Jason.decode!(value)
defp inferred_model_capabilities(model_id) do
normalized = String.downcase(model_id)
%{
supports_attachment:
String.contains?(normalized, "4o") or String.contains?(normalized, "vision") or
String.contains?(normalized, "llava"),
supports_tool_calls:
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
String.contains?(normalized, "tool"),
disables_reasoning: false
}
end
defp decode_model_capabilities_override(model_id) do
case get_setting("ai.model_capabilities.#{model_id}") do
nil -> %{}
value -> Jason.decode!(value) |> atomize_map_keys()
end
end
defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
defp persist_catalog(payload) do
now = Persistence.now_ms()
Repo.transaction(fn ->
Repo.delete_all(ModelModality)
Repo.delete_all(Model)
Repo.delete_all(CatalogProvider)
Enum.reduce(payload, 0, fn {provider_id, provider_data}, count ->
provider_attrs = %{
id: provider_id,
name: Map.get(provider_data, "name", provider_id),
env_keys: Jason.encode!(Map.get(provider_data, "env", [])),
package_ref: Map.get(provider_data, "npm"),
api_url: Map.get(provider_data, "api"),
doc_url: Map.get(provider_data, "doc"),
updated_at: now
}
%CatalogProvider{}
|> CatalogProvider.changeset(provider_attrs)
|> Repo.insert!()
models = Map.get(provider_data, "models", %{})
Enum.reduce(models, count, fn {model_id, model_data}, inner_count ->
model_attrs = %{
provider: provider_id,
model_id: model_id,
name: Map.get(model_data, "name", model_id),
family: Map.get(model_data, "family"),
supports_attachment: Map.get(model_data, "attachment", false),
supports_reasoning: Map.get(model_data, "reasoning", false),
supports_tool_calls: Map.get(model_data, "tool_call", false),
supports_structured_output: Map.get(model_data, "structured_output", false),
supports_temperature: Map.get(model_data, "temperature", false),
knowledge: Map.get(model_data, "knowledge"),
release_date: Map.get(model_data, "release_date"),
last_updated_date: Map.get(model_data, "last_updated"),
open_weights: Map.get(model_data, "open_weights", false),
input_price: get_in(model_data, ["cost", "input"]),
output_price: get_in(model_data, ["cost", "output"]),
cache_read_price: get_in(model_data, ["cost", "cache_read"]),
cache_write_price: get_in(model_data, ["cost", "cache_write"]),
context_window: get_in(model_data, ["limit", "context"]) || 0,
max_input_tokens: get_in(model_data, ["limit", "input"]) || 0,
max_output_tokens: get_in(model_data, ["limit", "output"]) || 0,
interleaved: encode_nullable(Map.get(model_data, "interleaved")),
status: Map.get(model_data, "status"),
updated_at: now
}
%Model{}
|> Model.changeset(model_attrs)
|> Repo.insert!()
insert_modalities(
provider_id,
model_id,
Map.get(model_data, "input_modalities", []),
:input
)
insert_modalities(
provider_id,
model_id,
Map.get(model_data, "output_modalities", []),
:output
)
inner_count + 1
end)
end)
end)
|> case do
{:ok, count} -> count
{:error, reason} -> raise reason
end
end
defp insert_modalities(provider_id, model_id, modalities, direction) do
Enum.each(modalities, fn modality ->
%ModelModality{}
|> ModelModality.changeset(%{
provider: provider_id,
model_id: model_id,
direction: direction,
modality: parse_modality(modality)
})
|> Repo.insert!()
end)
end
defp parse_modality("text"), do: :text
defp parse_modality("image"), do: :image
defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value)
defp http_get(client, url, headers) when is_atom(client), do: client.get(url, headers)
defp http_get(client, url, headers) when is_function(client, 2), do: client.(url, headers)
defp maybe_put_header(headers, _key, nil), do: headers
defp maybe_put_header(headers, key, value), do: Map.put(headers, key, value)
defp decode_json_list(nil), do: []
defp decode_json_list(value), do: Jason.decode!(value)
defp truthy?(value), do: value in [true, "true", 1, "1"]
end