defmodule BDS.AI.Chat do @moduledoc false import Ecto.Query require Logger alias BDS.AI alias BDS.AI.Catalog alias BDS.AI.CatalogProvider alias BDS.AI.ChatConversation alias BDS.AI.ChatMessage alias BDS.AI.ChatTools alias BDS.AI.InFlight alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.Runtime alias BDS.AI.SecretBackend alias BDS.MapUtils import BDS.AI.SettingsStore, only: [get_setting: 1] alias BDS.Media.Media alias BDS.Persistence alias BDS.Posts.Post alias BDS.Projects.Project alias BDS.Repo @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_max_output_tokens 16_384 @title_max_output_tokens 256 @chat_title_max_length 30 @chat_max_tool_rounds 10 @default_context_window 128_000 @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} def start_chat(attrs \\ %{}) when is_map(attrs) do now = Persistence.now_ms() model = MapUtils.attr(attrs, :model) title = MapUtils.attr(attrs, :title) || generated_chat_title(model) %ChatConversation{} |> ChatConversation.changeset(%{ id: Ecto.UUID.generate(), title: title, model: model, copilot_session_id: MapUtils.attr(attrs, :copilot_session_id), created_at: now, updated_at: now }) |> Repo.insert() |> case do {:ok, conversation} -> {:ok, format_conversation(conversation)} error -> error end end @spec list_chat_conversations() :: [map()] def list_chat_conversations do Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at]) |> Enum.map(&format_conversation/1) end @spec get_chat_conversation(String.t()) :: ChatConversation.t() | nil def get_chat_conversation(conversation_id) when is_binary(conversation_id) do Repo.get(ChatConversation, conversation_id) end @spec get_surface_state(String.t()) :: map() def get_surface_state(conversation_id) when is_binary(conversation_id) do case Repo.get(ChatConversation, conversation_id) do %ChatConversation{surface_state: state} when is_map(state) -> state _other -> %{} end end @spec put_surface_state(String.t(), map(), map(), MapSet.t()) :: {:ok, map()} | {:error, term()} def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) when is_binary(conversation_id) do case Repo.get(ChatConversation, conversation_id) do nil -> {:error, :not_found} %ChatConversation{} = conversation -> state = %{ "surface_data" => surface_data, "surface_tabs" => surface_tabs, "dismissed_surfaces" => MapSet.to_list(dismissed_surfaces) } conversation |> ChatConversation.changeset(%{ surface_state: state, updated_at: Persistence.now_ms() }) |> Repo.update() |> case do {:ok, _updated} -> {:ok, state} error -> error end end end @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()} def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do case Repo.get(ChatConversation, conversation_id) do nil -> {:error, :not_found} %ChatConversation{} = conversation -> Repo.delete_all( from message in ChatMessage, where: message.conversation_id == ^conversation_id ) case Repo.delete(conversation) do {:ok, _conversation} -> {:ok, :deleted} {:error, reason} -> {:error, reason} end end end @spec available_chat_models(String.t() | nil) :: [map()] def available_chat_models(current_model \\ nil) do endpoint_models = configured_chat_models() preference_models = [:chat, :airplane_chat] |> Enum.flat_map(fn key -> case AI.get_model_preference(key) do {:ok, model} when is_binary(model) and model != "" -> [model] _other -> [] end end) provider_names = catalog_provider_name_map() endpoint_provider_map = Map.new(endpoint_models, &{&1.id, &1.provider}) [current_model | Enum.map(endpoint_models, & &1.id) ++ preference_models] |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) |> Enum.uniq() |> Enum.map(&build_available_chat_model(&1, endpoint_provider_map, provider_names)) |> Enum.sort_by(fn model -> { String.downcase(to_string(model.provider_name || model.provider || "")), String.downcase(to_string(model.name || model.id)) } end) end @spec effective_chat_model(ChatConversation.t() | map() | nil) :: String.t() | nil def effective_chat_model(%ChatConversation{} = conversation) do resolve_effective_chat_model(conversation.model) end def effective_chat_model(%{model: model}), do: resolve_effective_chat_model(model) def effective_chat_model(%{"model" => model}), do: resolve_effective_chat_model(model) def effective_chat_model(_conversation), do: resolve_effective_chat_model(nil) @spec set_conversation_model(String.t(), String.t()) :: {:ok, map()} | {:error, :not_found | Ecto.Changeset.t()} def set_conversation_model(conversation_id, model_id) when is_binary(conversation_id) and is_binary(model_id) do case Repo.get(ChatConversation, conversation_id) do nil -> {:error, :not_found} %ChatConversation{} = conversation -> conversation |> ChatConversation.changeset(%{model: model_id, updated_at: Persistence.now_ms()}) |> Repo.update() |> case do {:ok, updated_conversation} -> {:ok, format_conversation(updated_conversation)} error -> error end end end @spec list_chat_messages(String.t()) :: [map()] def list_chat_messages(conversation_id) when is_binary(conversation_id) do Repo.all( from message in ChatMessage, where: message.conversation_id == ^conversation_id, order_by: [asc: message.created_at, asc: message.id] ) |> Enum.map(&format_chat_message/1) end @spec send_chat_message(String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, :not_found | term()} def send_chat_message(conversation_id, content, opts \\ []) when is_binary(conversation_id) and is_binary(content) and is_list(opts) do with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id), {:ok, user_message} <- persist_chat_message(%{ conversation_id: conversation.id, role: :user, content: content, created_at: Persistence.now_ms() }) do task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> receive do :sandbox_ready -> :ok end do_send_chat_message(conversation, user_message, opts) end) InFlight.register(conversation.id, task.pid) :ok = allow_repo_sandbox(task.pid) send(task.pid, :sandbox_ready) try do await_chat_task(task) after InFlight.unregister(conversation.id) end else nil -> {:error, :not_found} error -> error end end @spec cancel_chat(String.t()) :: :ok def cancel_chat(conversation_id) when is_binary(conversation_id) do case InFlight.lookup(conversation_id) do nil -> :ok pid -> _ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid) :ok end end @doc false def count_distinct_string_list(schema, field, project_id) do Repo.all( from record in schema, where: field(record, :project_id) == ^project_id, select: field(record, ^field) ) |> List.flatten() |> Enum.reject(&blank?/1) |> MapSet.new() |> MapSet.size() end @doc false def normalize_usage(usage) when is_map(usage) do %{ input_tokens: usage[:input_tokens] || usage["input_tokens"], output_tokens: usage[:output_tokens] || usage["output_tokens"], cache_read_tokens: usage[:cache_read_tokens] || usage["cache_read_tokens"], cache_write_tokens: usage[:cache_write_tokens] || usage["cache_write_tokens"] } end def normalize_usage(_usage) do %{ input_tokens: nil, output_tokens: nil, cache_read_tokens: nil, cache_write_tokens: nil } end defp format_conversation(conversation) do %{ id: conversation.id, title: conversation.title, model: conversation.model, copilot_session_id: conversation.copilot_session_id, created_at: conversation.created_at, updated_at: conversation.updated_at } end defp format_chat_message(message) do %{ id: message.id, conversation_id: message.conversation_id, role: message.role, content: message.content, tool_call_id: message.tool_call_id, tool_calls: Catalog.decode_nullable_json(message.tool_calls), token_usage_input: message.token_usage_input, token_usage_output: message.token_usage_output, cache_read_tokens: message.cache_read_tokens, cache_write_tokens: message.cache_write_tokens, created_at: message.created_at } end defp configured_chat_models do [:online, :airplane] |> Enum.flat_map(fn kind -> case AI.get_endpoint(kind) do {:ok, %{model: model, url: url}} when is_binary(model) and model != "" -> [%{id: model, provider: infer_endpoint_provider(kind, url)}] _other -> [] end end) end defp build_available_chat_model(model_id, endpoint_provider_map, provider_names) do case Catalog.get_catalog_model(model_id) do {:ok, model} -> provider = model.provider || Map.get(endpoint_provider_map, model_id, "other") %{ id: model.model_id, name: model.name || model.model_id, provider: provider, provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)), context_window: model.context_window, max_output_tokens: model.max_output_tokens } {:error, :not_found} -> provider = Map.get(endpoint_provider_map, model_id, "other") %{ id: model_id, name: model_id, provider: provider, provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)), context_window: nil, max_output_tokens: nil } end end defp resolve_effective_chat_model(model) when is_binary(model) and model != "", do: model defp resolve_effective_chat_model(_model) do mode = if AI.airplane_mode?(), do: :airplane, else: :online preference_key = if mode == :airplane, do: :airplane_chat, else: :chat case Runtime.model_preference_value(preference_key) do model when is_binary(model) and model != "" -> model _other -> case AI.get_endpoint(mode) do {:ok, %{model: model}} when is_binary(model) and model != "" -> model _other -> nil end end end defp catalog_provider_name_map do Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name}) |> Map.new() end defp infer_endpoint_provider(:online, _url), do: "generic-openai" defp infer_endpoint_provider(:airplane, url) when is_binary(url) do normalized_url = String.downcase(url) cond do String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama" String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio" true -> "generic-openai" end end defp infer_endpoint_provider(:airplane, _url), do: "generic-openai" defp fallback_provider_name("generic-openai"), do: "Generic OpenAI" defp fallback_provider_name("lmstudio"), do: "LM Studio" defp fallback_provider_name("mistral"), do: "Mistral" defp fallback_provider_name("ollama"), do: "Ollama" defp fallback_provider_name("openai"), do: "OpenAI" defp fallback_provider_name(provider) when is_binary(provider) and provider != "" do provider |> String.split(["-", "_"], trim: true) |> Enum.map(&String.capitalize/1) |> Enum.join(" ") end defp fallback_provider_name(_provider), do: "Other" defp do_send_chat_message(conversation, user_message, opts) do runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) project_id = Keyword.get(opts, :project_id, active_project_id()) with {:ok, endpoint, model, mode} <- Runtime.resolve_target( :chat, conversation: conversation, secret_backend: Keyword.get(opts, :secret_backend, SecretBackend) ), :ok <- Runtime.validate_target(:chat, model, mode), messages <- load_chat_messages(conversation.id), tools <- available_chat_tools(project_id, model), {:ok, reply} <- chat_round( conversation, messages, endpoint, model, project_id, tools, runtime, opts, chat_max_tool_rounds() ), {:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do {:ok, reply} end end defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do conversation = Repo.get!(ChatConversation, conversation_id) cond do chat_user_message_count(conversation_id) < 1 -> Logger.debug("Chat title generation skipped reason=:no_user_messages") {:ok, reply} not generated_chat_title?(conversation.title, conversation.model) -> Logger.debug( "Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}" ) {:ok, reply} true -> Logger.debug("Chat title generation requested conversation_id=#{conversation_id}") case generate_chat_title(user_content, opts) do {:ok, title} when is_binary(title) and title != "" -> now = Persistence.now_ms() conversation |> ChatConversation.changeset(%{title: title, updated_at: now}) |> Repo.update() |> case do {:ok, updated_conversation} -> {:ok, %{reply | conversation: format_conversation(updated_conversation)}} {:error, _reason} -> {:ok, reply} end _other -> {:ok, reply} end end end defp generate_chat_title(user_content, opts) when is_binary(user_content) do runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts), :ok <- Runtime.validate_target(:chat_title, model, mode), request <- build_chat_title_request(user_content, model), {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do title = sanitize_chat_title(Map.get(response, :content)) if title == "" do Logger.warning("Chat title generation returned an empty title", model: model, content: inspect(Map.get(response, :content)), usage: inspect(Map.get(response, :usage)) ) end {:ok, title} else {:error, reason} = error -> Logger.warning("Chat title generation failed", reason: inspect(reason)) error other -> Logger.warning("Chat title generation failed", reason: inspect(other)) other end end defp build_chat_title_request(user_content, model) do %{ operation: :chat_title, model: model, max_output_tokens: @title_max_output_tokens, messages: [ %{ "role" => "system", "content" => "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text." }, %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} ] } end defp sanitize_chat_title(title) when is_binary(title) do title = title |> String.trim() |> String.trim_leading("\"") |> String.trim_leading("'") |> String.trim_trailing("\"") |> String.trim_trailing("'") |> String.trim_trailing(".") |> String.trim_trailing("!") |> String.trim_trailing("?") if String.length(title) > @chat_title_max_length do String.slice(title, 0, @chat_title_max_length - 3) <> "..." else title end end defp sanitize_chat_title(_title), do: "" defp chat_user_message_count(conversation_id) do Repo.aggregate( from(message in ChatMessage, where: message.conversation_id == ^conversation_id and message.role == :user ), :count, :id ) end defp generated_chat_title?(title, model) do title in [generated_chat_title(nil), generated_chat_title(model)] end defp chat_round( _conversation, _messages, _endpoint, _model, _project_id, _tools, _runtime, _opts, 0 ) do {:error, %{kind: :tool_loop_exhausted}} end defp chat_round( conversation, messages, endpoint, model, project_id, tools, runtime, opts, rounds_left ) do request = build_chat_request(conversation, messages, model, project_id, tools) with {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts), {:ok, assistant_message} <- persist_assistant_response(conversation.id, response), :ok <- touch_conversation(conversation.id) do if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do notify_chat_event( opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)} ) end tool_calls = decode_tool_calls(Map.get(response, :tool_calls)) Enum.each(tool_calls, fn tool_call -> notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call}) end) cond do tool_calls != [] and tools != [] -> with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts), updated_messages <- load_chat_messages(conversation.id), {:ok, reply} <- chat_round( Repo.get!(ChatConversation, conversation.id), updated_messages, endpoint, model, project_id, tools, runtime, opts, rounds_left - 1 ) do {:ok, Map.put(reply, :tool_messages, tool_messages)} end true -> {:ok, %{ conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)), assistant_message: format_chat_message(assistant_message), tool_messages: [] }} end end end defp persist_assistant_response(conversation_id, response) do usage = normalize_usage(response.usage) content = case Map.get(response, :content) do nil -> encode_nullable(Map.get(response, :json)) value -> value end persist_chat_message(%{ conversation_id: conversation_id, role: :assistant, content: content, tool_calls: encode_nullable(Map.get(response, :tool_calls)), token_usage_input: usage.input_tokens, token_usage_output: usage.output_tokens, cache_read_tokens: usage.cache_read_tokens, cache_write_tokens: usage.cache_write_tokens, created_at: Persistence.now_ms() }) end defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do tool_messages = Enum.map(tool_calls, fn tool_call -> result = ChatTools.execute(tool_call.name, tool_call.arguments || %{}, project_id) {:ok, message} = persist_chat_message(%{ conversation_id: conversation_id, role: :tool, content: Jason.encode!(result), tool_call_id: tool_call.id, created_at: Persistence.now_ms() }) notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name}) format_chat_message(message) end) {:ok, tool_messages} end defp build_chat_request(conversation, messages, model, project_id, tools) do system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)} %{ operation: :chat, conversation_id: conversation.id, model: model, max_output_tokens: @default_max_output_tokens, tools: Enum.map(tools, & &1.spec), messages: [system_message | Enum.map(messages, &message_for_runtime/1)] |> truncate_chat_messages(model, tools) } end defp message_for_runtime(%ChatMessage{} = message) do base = %{"role" => Atom.to_string(message.role)} base = if is_binary(message.content), do: Map.put(base, "content", message.content), else: base base = if is_binary(message.tool_call_id), do: Map.put(base, "tool_call_id", message.tool_call_id), else: base case Catalog.decode_nullable_json(message.tool_calls) do nil -> base tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls)) end end defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do Enum.map(tool_calls, &tool_call_for_runtime/1) end defp tool_calls_for_runtime(tool_calls), do: tool_calls defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do tool_call end defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do %{ "id" => id, "type" => "function", "function" => %{ "name" => name, "arguments" => Jason.encode!(tool_call["arguments"] || %{}) } } end defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do %{ "id" => id, "type" => "function", "function" => %{ "name" => name, "arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{}) } } end defp tool_call_for_runtime(tool_call), do: tool_call defp truncate_chat_messages(messages, model, tools) do context_window = model_context_window(model) reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512)) tool_budget = length(tools) * 120 max_budget = max(context_window - reserve - tool_budget, 512) [system | remainder] = messages {kept, _tokens} = Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message, {acc, used} -> message_tokens = approximate_message_tokens(message) if used + message_tokens <= max_budget do {[message | acc], used + message_tokens} else {acc, used} end end) [system | kept] end defp available_chat_tools(project_id, model) do ChatTools.available_specs(project_id, Catalog.model_capabilities(model)) end # BoundedToolLoop: the tool-calling round count is capped by # config.chat_max_tool_rounds (falling back to the built-in default). defp chat_max_tool_rounds do :bds |> Application.get_env(:chat, []) |> Keyword.get(:max_tool_rounds, @chat_max_tool_rounds) end defp chat_system_prompt(project_id, tools) do base = get_setting("ai.system_prompt") || @default_system_prompt 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 defp blog_tool_guidance do Enum.join( [ "Available blog data tools:", "- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.", "- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.", "- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.", "- Use read_post_by_slug to read full post content and metadata when a slug is known.", "- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.", "- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.", "- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.", "- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship questions.", "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.", "", "Available UI Render Tools (use these to show rich interactive elements):", "- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.", "- render_table: Show data in a structured table. Use for tabular comparisons and listings.", "- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).", "- render_card: Show an information card with title, body, and action buttons.", "- render_metric: Show a single KPI or statistic prominently.", "- render_list: Show a bulleted list of items.", "- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.", "", "When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data." ], "\n" ) end defp project_stats_summary(nil), do: nil defp project_stats_summary(project_id) do post_count = Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id) media_count = Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id) tag_count = count_distinct_string_list(Post, :tags, project_id) category_count = count_distinct_string_list(Post, :categories, project_id) Enum.join( [ "Posts: #{post_count}", "Media: #{media_count}", "Tags: #{tag_count}", "Categories: #{category_count}" ], "\n" ) end defp generated_chat_title(nil), do: "New Chat" defp generated_chat_title(model), do: "Chat with #{model}" defp load_chat_messages(conversation_id) do Repo.all( from message in ChatMessage, where: message.conversation_id == ^conversation_id, order_by: [asc: message.created_at, asc: message.id] ) end defp persist_chat_message(attrs) do %ChatMessage{} |> ChatMessage.changeset(attrs) |> Repo.insert() end defp touch_conversation(conversation_id) do now = Persistence.now_ms() Repo.update_all( from(conversation in ChatConversation, where: conversation.id == ^conversation_id), set: [updated_at: now] ) :ok end defp await_chat_task(task) do ref = task.ref receive do {^ref, result} -> Process.demonitor(task.ref, [:flush]) result {:DOWN, ^ref, :process, _pid, reason} -> case reason do :normal -> receive do {^ref, result} -> result after 10 -> {:error, :cancelled} end :shutdown -> {:error, :cancelled} {:shutdown, _detail} -> {:error, :cancelled} _other -> {:error, :cancelled} end end end defp decode_tool_calls(nil), do: [] defp decode_tool_calls(tool_calls) when is_list(tool_calls) do Enum.map(tool_calls, fn tool_call -> %{ id: tool_call[:id] || tool_call["id"], name: tool_call[:name] || tool_call["name"], arguments: tool_call[:arguments] || tool_call["arguments"] || %{} } end) end defp approximate_message_tokens(message) when is_map(message) do message |> Map.values() |> Enum.map(&approximate_value_tokens/1) |> Enum.sum() |> Kernel.+(4) end defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1 defp approximate_value_tokens(value) when is_list(value), do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum() defp approximate_value_tokens(value) when is_map(value), do: Jason.encode!(value) |> approximate_value_tokens() defp approximate_value_tokens(_value), do: 1 defp model_context_window(model_id) do case Catalog.get_catalog_model(model_id) do {:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> model.context_window _other -> @default_context_window end end defp notify_chat_event(opts, event) do case Keyword.get(opts, :event_target) do pid when is_pid(pid) -> send(pid, event) callback when is_function(callback, 1) -> callback.(event) _other -> :ok end :ok end defp active_project_id do Repo.one(from project in Project, where: project.is_active == true, select: project.id) end defp allow_repo_sandbox(pid) when is_pid(pid) do if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do try do Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) rescue _error -> :ok end else :ok end :ok end defp encode_nullable(nil), do: nil defp encode_nullable(value), do: Jason.encode!(value) defp blank?(value), do: value in [nil, ""] end