feat: start on AI integration
This commit is contained in:
1314
lib/bds/ai.ex
Normal file
1314
lib/bds/ai.ex
Normal file
File diff suppressed because it is too large
Load Diff
18
lib/bds/ai/catalog_meta.ex
Normal file
18
lib/bds/ai/catalog_meta.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule BDS.AI.CatalogMeta do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:key, :string, autogenerate: false}
|
||||
|
||||
schema "ai_catalog_meta" do
|
||||
field :value, :string
|
||||
end
|
||||
|
||||
def changeset(meta, attrs) do
|
||||
meta
|
||||
|> cast(attrs, [:key, :value], empty_values: [nil])
|
||||
|> validate_required([:key, :value])
|
||||
end
|
||||
end
|
||||
23
lib/bds/ai/catalog_provider.ex
Normal file
23
lib/bds/ai/catalog_provider.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule BDS.AI.CatalogProvider do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
|
||||
schema "ai_providers" do
|
||||
field :name, :string
|
||||
field :env_keys, :string, source: :env
|
||||
field :package_ref, :string
|
||||
field :api_url, :string, source: :api
|
||||
field :doc_url, :string, source: :doc
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(provider, attrs) do
|
||||
provider
|
||||
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil])
|
||||
|> validate_required([:id, :name, :updated_at])
|
||||
end
|
||||
end
|
||||
22
lib/bds/ai/chat_conversation.ex
Normal file
22
lib/bds/ai/chat_conversation.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule BDS.AI.ChatConversation do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
|
||||
schema "chat_conversations" do
|
||||
field :title, :string
|
||||
field :model, :string
|
||||
field :copilot_session_id, :string
|
||||
field :created_at, :integer
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], empty_values: [nil])
|
||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||
end
|
||||
end
|
||||
41
lib/bds/ai/chat_message.ex
Normal file
41
lib/bds/ai/chat_message.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule BDS.AI.ChatMessage do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@foreign_key_type :string
|
||||
@roles [:system, :user, :assistant, :tool]
|
||||
|
||||
schema "chat_messages" do
|
||||
belongs_to :conversation, BDS.AI.ChatConversation, references: :id, type: :string
|
||||
|
||||
field :role, Ecto.Enum, values: @roles
|
||||
field :content, :string
|
||||
field :tool_call_id, :string
|
||||
field :tool_calls, :string
|
||||
field :token_usage_input, :integer
|
||||
field :token_usage_output, :integer
|
||||
field :cache_read_tokens, :integer
|
||||
field :cache_write_tokens, :integer
|
||||
field :created_at, :integer
|
||||
end
|
||||
|
||||
def changeset(message, attrs) do
|
||||
message
|
||||
|> cast(attrs, [
|
||||
:conversation_id,
|
||||
:role,
|
||||
:content,
|
||||
:tool_call_id,
|
||||
:tool_calls,
|
||||
:token_usage_input,
|
||||
:token_usage_output,
|
||||
:cache_read_tokens,
|
||||
:cache_write_tokens,
|
||||
:created_at
|
||||
], empty_values: [nil])
|
||||
|> validate_required([:conversation_id, :role, :created_at])
|
||||
|> assoc_constraint(:conversation)
|
||||
end
|
||||
end
|
||||
51
lib/bds/ai/http_client.ex
Normal file
51
lib/bds/ai/http_client.ex
Normal file
@@ -0,0 +1,51 @@
|
||||
defmodule BDS.AI.HttpClient do
|
||||
@moduledoc false
|
||||
|
||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
||||
request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)}
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
|
||||
case :httpc.request(:get, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: body
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def post(url, headers, body)
|
||||
when is_binary(url) and is_map(headers) and is_binary(body) do
|
||||
request =
|
||||
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body}
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
|
||||
case :httpc.request(:post, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: response_body
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_headers(headers) do
|
||||
Enum.into(headers, %{}, fn {key, value} ->
|
||||
{key |> to_string() |> String.downcase(), to_string(value)}
|
||||
end)
|
||||
end
|
||||
end
|
||||
29
lib/bds/ai/in_flight.ex
Normal file
29
lib/bds/ai/in_flight.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule BDS.AI.InFlight do
|
||||
@moduledoc false
|
||||
|
||||
@table :bds_ai_in_flight
|
||||
|
||||
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
||||
:ets.insert(table(), {conversation_id, pid})
|
||||
:ok
|
||||
end
|
||||
|
||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||
:ets.delete(table(), conversation_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
def lookup(conversation_id) when is_binary(conversation_id) do
|
||||
case :ets.lookup(table(), conversation_id) do
|
||||
[{^conversation_id, pid}] -> pid
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp table do
|
||||
case :ets.whereis(@table) do
|
||||
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||
table -> table
|
||||
end
|
||||
end
|
||||
end
|
||||
64
lib/bds/ai/model.ex
Normal file
64
lib/bds/ai/model.ex
Normal file
@@ -0,0 +1,64 @@
|
||||
defmodule BDS.AI.Model do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key false
|
||||
|
||||
schema "ai_models" do
|
||||
field :provider, :string, primary_key: true
|
||||
field :model_id, :string, primary_key: true
|
||||
field :name, :string
|
||||
field :family, :string
|
||||
field :supports_attachment, :boolean, source: :attachment, default: false
|
||||
field :supports_reasoning, :boolean, source: :reasoning, default: false
|
||||
field :supports_tool_calls, :boolean, source: :tool_call, default: false
|
||||
field :supports_structured_output, :boolean, source: :structured_output, default: false
|
||||
field :supports_temperature, :boolean, source: :temperature, default: false
|
||||
field :knowledge, :string
|
||||
field :release_date, :string
|
||||
field :last_updated_date, :string
|
||||
field :open_weights, :boolean, default: false
|
||||
field :input_price, :integer
|
||||
field :output_price, :integer
|
||||
field :cache_read_price, :integer
|
||||
field :cache_write_price, :integer
|
||||
field :context_window, :integer
|
||||
field :max_input_tokens, :integer
|
||||
field :max_output_tokens, :integer
|
||||
field :interleaved, :string
|
||||
field :status, :string
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(model, attrs) do
|
||||
model
|
||||
|> cast(attrs, [
|
||||
:provider,
|
||||
:model_id,
|
||||
:name,
|
||||
:family,
|
||||
:supports_attachment,
|
||||
:supports_reasoning,
|
||||
:supports_tool_calls,
|
||||
:supports_structured_output,
|
||||
:supports_temperature,
|
||||
:knowledge,
|
||||
:release_date,
|
||||
:last_updated_date,
|
||||
:open_weights,
|
||||
:input_price,
|
||||
:output_price,
|
||||
:cache_read_price,
|
||||
:cache_write_price,
|
||||
:context_window,
|
||||
:max_input_tokens,
|
||||
:max_output_tokens,
|
||||
:interleaved,
|
||||
:status,
|
||||
:updated_at
|
||||
], empty_values: [nil])
|
||||
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
|
||||
end
|
||||
end
|
||||
21
lib/bds/ai/model_modality.ex
Normal file
21
lib/bds/ai/model_modality.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule BDS.AI.ModelModality do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key false
|
||||
|
||||
schema "ai_model_modalities" do
|
||||
field :provider, :string, primary_key: true
|
||||
field :model_id, :string, primary_key: true
|
||||
field :direction, Ecto.Enum, values: [:input, :output], primary_key: true
|
||||
field :modality, Ecto.Enum, values: [:text, :image, :audio, :file, :tool], primary_key: true
|
||||
end
|
||||
|
||||
def changeset(modality, attrs) do
|
||||
modality
|
||||
|> cast(attrs, [:provider, :model_id, :direction, :modality], empty_values: [nil])
|
||||
|> validate_required([:provider, :model_id, :direction, :modality])
|
||||
end
|
||||
end
|
||||
108
lib/bds/ai/openai_compatible_runtime.ex
Normal file
108
lib/bds/ai/openai_compatible_runtime.ex
Normal file
@@ -0,0 +1,108 @@
|
||||
defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.AI.HttpClient
|
||||
|
||||
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
|
||||
url = completions_url(endpoint.url)
|
||||
|
||||
headers =
|
||||
%{
|
||||
"content-type" => "application/json",
|
||||
"accept" => "application/json"
|
||||
}
|
||||
|> maybe_put_auth(endpoint.api_key)
|
||||
|
||||
payload = %{
|
||||
"model" => request.model,
|
||||
"messages" => request.messages,
|
||||
"max_tokens" => request.max_output_tokens
|
||||
}
|
||||
|> maybe_put_tools(request.tools)
|
||||
|
||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
||||
200 <- response.status do
|
||||
normalize_response(response.body)
|
||||
else
|
||||
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
|
||||
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_response(body) do
|
||||
payload = Jason.decode!(body)
|
||||
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
|
||||
content = normalize_content(message["content"])
|
||||
tool_calls = normalize_tool_calls(message["tool_calls"] || [])
|
||||
usage = normalize_usage(payload["usage"] || %{})
|
||||
|
||||
json =
|
||||
case content do
|
||||
nil -> nil
|
||||
value when is_binary(value) ->
|
||||
case Jason.decode(value) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
|
||||
end
|
||||
|
||||
defp completions_url(url) do
|
||||
cond do
|
||||
String.ends_with?(url, "/chat/completions") -> url
|
||||
String.ends_with?(url, "/") -> url <> "chat/completions"
|
||||
true -> url <> "/chat/completions"
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_auth(headers, nil), do: headers
|
||||
defp maybe_put_auth(headers, ""), do: headers
|
||||
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")
|
||||
|
||||
defp maybe_put_tools(payload, []), do: payload
|
||||
defp maybe_put_tools(payload, nil), do: payload
|
||||
|
||||
defp maybe_put_tools(payload, tools) do
|
||||
Map.put(payload, "tools", tools)
|
||||
|> Map.put("tool_choice", "auto")
|
||||
end
|
||||
|
||||
defp normalize_tool_calls(tool_calls) do
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
%{
|
||||
id: tool_call["id"],
|
||||
name: get_in(tool_call, ["function", "name"]),
|
||||
arguments: decode_arguments(get_in(tool_call, ["function", "arguments"]))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp decode_arguments(nil), do: %{}
|
||||
|
||||
defp decode_arguments(arguments) when is_binary(arguments) do
|
||||
case Jason.decode(arguments) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_content(nil), do: nil
|
||||
defp normalize_content(content) when is_binary(content), do: content
|
||||
|
||||
defp normalize_content(content) when is_list(content) do
|
||||
content
|
||||
|> Enum.map(fn item -> item["text"] || "" end)
|
||||
|> Enum.join()
|
||||
end
|
||||
|
||||
defp normalize_usage(usage) do
|
||||
%{
|
||||
input_tokens: usage["prompt_tokens"],
|
||||
output_tokens: usage["completion_tokens"],
|
||||
cache_read_tokens: get_in(usage, ["prompt_tokens_details", "cached_tokens"]),
|
||||
cache_write_tokens: get_in(usage, ["completion_tokens_details", "cached_tokens"])
|
||||
}
|
||||
end
|
||||
end
|
||||
33
lib/bds/ai/secret_backend.ex
Normal file
33
lib/bds/ai/secret_backend.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule BDS.AI.SecretBackend do
|
||||
@moduledoc false
|
||||
|
||||
@aad "bds-ai-secret"
|
||||
|
||||
def encrypt(value) when is_binary(value) do
|
||||
key = secret_key()
|
||||
iv = :crypto.strong_rand_bytes(12)
|
||||
|
||||
{ciphertext, tag} =
|
||||
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
||||
|
||||
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
||||
end
|
||||
|
||||
def decrypt(encoded) when is_binary(encoded) do
|
||||
with {:ok, binary} <- Base.decode64(encoded),
|
||||
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
||||
plaintext when is_binary(plaintext) <-
|
||||
:crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do
|
||||
{:ok, plaintext}
|
||||
else
|
||||
_other -> {:error, :invalid_ciphertext}
|
||||
end
|
||||
end
|
||||
|
||||
defp secret_key do
|
||||
case Application.get_env(:bds, :ai_secret_key) do
|
||||
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32)
|
||||
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user