From e4db1d6d628d8a53bad88aa855df94b603e232d1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 21:49:31 +0200 Subject: [PATCH] feat: added tool support setup for models --- lib/bds/ai/chat.ex | 15 ++++---- .../shell_live/settings_editor/ai_settings.ex | 34 ++++++++++++++++++ .../settings_editor.html.heex | 8 +++++ test/bds/ai_test.exs | 36 +++++++++++++++++++ test/bds/desktop/shell_live_test.exs | 5 +++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index 1a4063f..f3aa36a 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -452,7 +452,7 @@ defmodule BDS.AI.Chat do end defp build_chat_request(conversation, messages, model, project_id, tools) do - system_message = %{"role" => "system", "content" => chat_system_prompt(project_id)} + system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)} %{ operation: :chat, @@ -511,15 +511,14 @@ defmodule BDS.AI.Chat do ChatTools.available_specs(project_id, Catalog.model_capabilities(model)) end - defp chat_system_prompt(project_id) do + defp chat_system_prompt(project_id, tools) do base = get_setting("ai.system_prompt") || @default_system_prompt - case project_stats_summary(project_id) do - nil -> - base - - summary -> - base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance() + with true <- tools != [], + summary when is_binary(summary) <- project_stats_summary(project_id) do + base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance() + else + _other -> base end end diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index 14ff6be..b141427 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -17,6 +17,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do "online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""), "online_chat_model" => get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""), + "online_chat_tools" => + model_supports_tool_calls?( + get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "") + ), "online_title_model" => get_model_preference(:title), "online_image_analysis_model" => get_model_preference(:image_analysis), "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""), @@ -25,6 +29,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do "offline_chat_model" => get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, ""), + "offline_chat_tools" => + model_supports_tool_calls?( + get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "") + ), "offline_title_model" => get_model_preference(:airplane_title), "offline_image_analysis_model" => get_model_preference(:airplane_image_analysis), "system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || "" @@ -90,9 +98,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do :ok <- AI.delete_endpoint(:mistral), :ok <- AI.set_airplane_mode(attrs.offline_mode), :ok <- maybe_put_model_preference(:chat, attrs.online_chat_model), + :ok <- + maybe_put_chat_model_capabilities(attrs.online_chat_model, attrs.online_chat_tools), :ok <- maybe_put_model_preference(:title, attrs.online_title_model), :ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), + :ok <- + maybe_put_chat_model_capabilities(attrs.offline_chat_model, attrs.offline_chat_tools), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- maybe_put_model_preference( @@ -134,12 +146,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do online_url: blank_to_nil(Map.get(draft, "online_url")), online_api_key: blank_to_nil(Map.get(draft, "online_api_key")), online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")), + online_chat_tools: truthy?(Map.get(draft, "online_chat_tools")), online_title_model: blank_to_nil(Map.get(draft, "online_title_model")), online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")), offline_url: blank_to_nil(Map.get(draft, "offline_url")), offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")), offline_mode: truthy?(Map.get(draft, "offline_mode")), offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")), + offline_chat_tools: truthy?(Map.get(draft, "offline_chat_tools")), offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")), offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")), system_prompt: Map.get(draft, "system_prompt", "") @@ -151,12 +165,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do "online_url" => Map.get(params, "online_url", ""), "online_api_key" => Map.get(params, "online_api_key", ""), "online_chat_model" => Map.get(params, "online_chat_model", ""), + "online_chat_tools" => truthy?(Map.get(params, "online_chat_tools")), "online_title_model" => Map.get(params, "online_title_model", ""), "online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""), "offline_url" => Map.get(params, "offline_url", ""), "offline_api_key" => Map.get(params, "offline_api_key", ""), "offline_mode" => truthy?(Map.get(params, "offline_mode")), "offline_chat_model" => Map.get(params, "offline_chat_model", ""), + "offline_chat_tools" => truthy?(Map.get(params, "offline_chat_tools")), "offline_title_model" => Map.get(params, "offline_title_model", ""), "offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""), "system_prompt" => Map.get(params, "system_prompt", "") @@ -174,6 +190,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do defp maybe_put_model_preference(_key, ""), do: :ok defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value) + defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls), do: :ok + defp maybe_put_chat_model_capabilities("", _supports_tool_calls), do: :ok + + defp maybe_put_chat_model_capabilities(model, supports_tool_calls) do + existing = BDS.AI.Catalog.model_capabilities(model) + + AI.put_model_capabilities(model, %{ + supports_attachment: existing.supports_attachment, + supports_tool_calls: supports_tool_calls + }) + end + + defp model_supports_tool_calls?(nil), do: false + defp model_supports_tool_calls?(""), do: false + + defp model_supports_tool_calls?(model), + do: BDS.AI.Catalog.model_capabilities(model).supports_tool_calls + defp put_endpoint_preferences(kind, url, api_key, primary_model) do if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do AI.delete_endpoint(kind) diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex index 1b4bed7..0892a0f 100644 --- a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex +++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex @@ -229,6 +229,10 @@
+
+
+
+
@@ -258,6 +262,10 @@
+
+
+
+
diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index d8785b9..3726abf 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -604,6 +604,42 @@ defmodule BDS.AITest do assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) end + test "chat does not prompt models to emit textual tool calls when tools are unavailable" do + {:ok, project} = create_project_fixture("No Tool Chat") + _fixtures = seed_project_content(project.id) + + assert {:ok, _endpoint} = + BDS.AI.put_endpoint( + :airplane, + %{ + url: "http://localhost:11434/v1", + api_key: nil, + model: "llama-plain" + }, + secret_backend: FakeSecretBackend + ) + + assert :ok = BDS.AI.set_airplane_mode(true) + assert :ok = BDS.AI.put_model_preference(:airplane_chat, "llama-plain") + assert {:ok, conversation} = BDS.AI.start_chat(%{model: "llama-plain"}) + + assert {:ok, _reply} = + BDS.AI.send_chat_message(conversation.id, "Show posts per month", + runtime: FakeRuntime, + test_pid: self(), + project_id: project.id, + secret_backend: FakeSecretBackend + ) + + assert_received {:runtime_request, _endpoint, first_request} + assert first_request.tools == [] + + refute Enum.any?(first_request.messages, fn message -> + message["role"] == "system" and + String.contains?(message["content"], "Available blog data tools") + end) + end + test "non-stat chat tools expose concrete project data" do {:ok, project} = create_project_fixture("Concrete Tools") %{post: post, media: media} = seed_project_content(project.id) diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index dcc55bc..2ff3772 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -770,11 +770,13 @@ defmodule BDS.Desktop.ShellLiveTest do "online_url" => "https://api.example.test/v1", "online_api_key" => "online-secret", "online_chat_model" => "gpt-4.1", + "online_chat_tools" => "true", "online_title_model" => "gpt-4.1-mini", "online_image_analysis_model" => "gpt-4.1-vision", "offline_url" => "http://localhost:11434/v1", "offline_api_key" => "", "offline_chat_model" => "llama3.3", + "offline_chat_tools" => "true", "offline_title_model" => "llama3.2", "offline_image_analysis_model" => "llava:latest", "offline_mode" => "true", @@ -802,6 +804,9 @@ defmodule BDS.Desktop.ShellLiveTest do assert {:ok, "llama3.3"} = AI.get_model_preference(:airplane_chat) assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title) assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis) + + assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("gpt-4.1") + assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("llama3.3") end test "ai settings refresh models from the configured endpoints" do