defmodule BamlElixir.Client do @moduledoc """ A client for interacting with BAML functions. Data structures and functions are generated from BAML source files. > #### `use BamlElixir.Client, path: "priv/baml_src"` {: .info} > > When you `use BamlElixir.Client`, it will define: > - A module for each function in the BAML source files with `call/2` and `stream/3` functions along with the types. > - A module with `defstruct/1` and `@type t/0` for each class in the BAML source file. > - A module with `@type t/0` for each enum in the BAML source file. > > The `path` option is optional and defaults to `"baml_src"`, you may want to set it to `"priv/baml_src"`. This module also provides functionality to call BAML functions either sync/async. """ defmacro __using__(opts) do path = Keyword.get(opts, :path, "baml_src") {baml_src_path, _} = Code.eval_quoted(app_path(path), [], __CALLER__) # Get all .baml files in the directory baml_files = get_baml_files(baml_src_path) # Add @external_resource for each BAML file to establish compile-time dependencies for baml_file <- baml_files do quote do @external_resource unquote(baml_file) end end # Get BAML types baml_types = BamlElixir.Native.parse_baml(baml_src_path) baml_class_types = baml_types[:classes] baml_enum_types = baml_types[:enums] baml_functions = baml_types[:functions] baml_class_types_quoted = generate_class_types(baml_class_types, __CALLER__) baml_enum_types_quoted = generate_enum_types(baml_enum_types, __CALLER__) baml_functions_quoted = generate_function_modules(baml_functions, path, __CALLER__) recompile_function = generate_recompile_function(baml_src_path, baml_files) quote do import BamlElixir.Client unquote(baml_class_types_quoted) unquote(baml_enum_types_quoted) unquote(baml_functions_quoted) unquote(recompile_function) end end @doc """ Calls a BAML function synchronously. ## Parameters - `function_name`: The name of the BAML function to call - `args`: A map of arguments to pass to the function - `opts`: A map of options - `path`: The path to the BAML source file - `collectors`: A list of collectors to use - `llm_client`: The name of the LLM client to use ## Returns - `{:ok, term()}` on success, where the term is the function's return value - `{:error, String.t()}` on failure, with an error message ## Examples {:ok, result} = BamlElixir.Client.call(client, "MyFunction", %{arg1: "value"}) """ @spec call(String.t(), map(), map()) :: {:ok, term()} | {:error, String.t()} def call(function_name, args, opts \\ %{}) do {path, collectors, client_registry, tb} = prepare_opts(opts) args = to_map(args) with {:ok, result} <- BamlElixir.Native.call(function_name, args, path, collectors, client_registry, tb) do result = if opts[:parse] != false do parse_result(result, opts[:prefix], tb) else result end {:ok, result} end end @doc """ Streams a BAML function asynchronously. ## Parameters - `function_name`: The name of the BAML function to stream - `args`: A map of arguments to pass to the function - `callback`: A function that will be called with the result of the function - `opts`: A map of options - `path`: The path to the BAML source file - `collectors`: A list of collectors to use - `llm_client`: The name of the LLM client to use """ def stream(function_name, args, callback, opts \\ %{}) do ref = make_ref() args = to_map(args) spawn_link(fn -> start_sync_stream(self(), ref, function_name, args, opts) handle_stream_result(ref, callback, opts) end) end @doc """ Streams partial output and also blocks until the function is done. Finally returns {:ok, result} or {:error, error} """ def sync_stream(function_name, args, callback, opts \\ %{}) do pid = self() stream( function_name, args, fn {:partial, result} -> callback.(result) result -> send(pid, {:done, result}) end, opts ) receive do {:done, {:error, error}} -> {:error, error} {:done, {:done, result}} -> {:ok, result} end end def app_path(path) do case path do {app, path} -> Application.app_dir(app, path) _ -> path end end # Get all .baml files in the specified directory def get_baml_files(baml_src_path) do if File.exists?(baml_src_path) and File.dir?(baml_src_path) do Path.wildcard(Path.join(baml_src_path, "**/*.baml")) else [] end end # Create a hash from a list of file paths def create_files_hash(file_paths) do file_paths |> Enum.map(&File.stat!/1) |> Enum.map(fn stat -> {stat.mtime, stat.size} end) |> inspect() |> :erlang.md5() end # Generate the __mix_recompile__?/0 function that checks if any .baml files have changed defp generate_recompile_function(baml_src_path, baml_files) do # Create a hash of all BAML files at compile time files_hash = create_files_hash(baml_files) quote do def __mix_recompile__?() do baml_src_path = unquote(baml_src_path) # Check if the directory still exists if not File.exists?(baml_src_path) or not File.dir?(baml_src_path) do true else # Get current BAML files and compare hashes current_baml_files = BamlElixir.Client.get_baml_files(baml_src_path) current_files_hash = BamlElixir.Client.create_files_hash(current_baml_files) current_files_hash != unquote(files_hash) end end end end defp start_sync_stream(pid, ref, function_name, args, opts) do {path, collectors, client_registry, tb} = prepare_opts(opts) spawn_link(fn -> result = BamlElixir.Native.stream( pid, ref, function_name, args, path, collectors, client_registry, tb ) send(pid, {ref, result}) end) end defp handle_stream_result(ref, callback, opts) do receive do {^ref, {:partial, result}} -> result = if opts[:parse] != false do parse_result(result, opts[:prefix], opts[:tb]) else result end callback.({:partial, result}) handle_stream_result(ref, callback, opts) {^ref, {:error, _} = msg} -> callback.(msg) {^ref, {:done, result}} -> result = if opts[:parse] != false do parse_result(result, opts[:prefix], opts[:tb]) else result end callback.({:done, result}) end end # Every class in the BAML source file is converted to an Elixir module # with a `defstruct/1` and a `@type t/0` type. defp generate_class_types(class_types, caller) do module = caller.module for {type_name, %{"fields" => fields, "dynamic" => dynamic}} <- class_types do field_names = get_field_names(fields) field_types = get_field_types(fields, caller) module_name = Module.concat([module, type_name]) quote do defmodule unquote(module_name) do defstruct unquote(field_names) @type t :: %__MODULE__{unquote_splicing(field_types)} def name, do: unquote(type_name) def type, do: :class def dynamic?, do: unquote(dynamic) end end end end # Every enum in the BAML source file is converted to an Elixir module # with a `@type t/0` type. defp generate_enum_types(enum_types, caller) do module = caller.module for {enum_name, variants} <- enum_types do variant_atoms = Enum.map(variants, &String.to_atom/1) module_name = Module.concat([module, enum_name]) union_type = Enum.reduce(variant_atoms, fn atom, acc -> {:|, [], [atom, acc]} end) quote do defmodule unquote(module_name) do @type t :: unquote(union_type) def name, do: unquote(enum_name) def values, do: unquote(variant_atoms) def type, do: :enum end end end end # Every function in the BAML source file is converted to an Elixir module # which has a `call/2` function and a `stream/3` function. defp generate_function_modules(functions, path, caller) do module = caller.module for {function_name, function_info} <- functions do module_name = Module.concat(module, function_name) param_types = for {param_name, param_type} <- function_info["params"] do {String.to_atom(param_name), to_elixir_type(param_type, caller)} end return_type = to_elixir_type(function_info["return_type"], caller) quote do defmodule unquote(module_name) do @spec call(%{unquote_splicing(param_types)}, map()) :: {:ok, unquote(return_type)} | {:error, String.t()} def call(args, opts \\ %{}) do opts = opts |> Map.put(:path, BamlElixir.Client.app_path(unquote(path))) |> Map.put(:prefix, unquote(module)) BamlElixir.Client.call(unquote(function_name), args, opts) end @spec stream( %{unquote_splicing(param_types)}, ({:ok, unquote(return_type) | {:error, String.t()} | :done} -> any()), map() ) :: Enumerable.t() def stream(args, callback, opts \\ %{}) do opts = opts |> Map.put(:path, BamlElixir.Client.app_path(unquote(path))) |> Map.put(:prefix, unquote(module)) BamlElixir.Client.stream(unquote(function_name), args, callback, opts) end @spec sync_stream( %{unquote_splicing(param_types)}, (unquote(return_type) -> any()), map() ) :: {:ok, unquote(return_type)} | {:error, String.t()} def sync_stream(args, callback, opts \\ %{}) do opts = opts |> Map.put(:path, BamlElixir.Client.app_path(unquote(path))) |> Map.put(:prefix, unquote(module)) BamlElixir.Client.sync_stream(unquote(function_name), args, callback, opts) end end end end end defp to_elixir_type(type, caller) do case type do {:primitive, primitive} -> case primitive do :string -> quote(do: String.t()) :integer -> quote(do: integer()) :float -> quote(do: float()) :boolean -> quote(do: boolean()) nil -> quote(do: nil) :media -> quote( do: %{url: String.t()} | %{url: String.t(), media_type: String.t()} | %{base64: String.t()} | %{base64: String.t(), media_type: String.t()} ) end {:enum, name} -> # Convert enum name to module reference with .t() module = Module.concat([caller.module, name]) quote(do: unquote(module).t()) {:class, name} -> # Convert class name to module reference with .t() module = Module.concat([caller.module, name]) quote(do: unquote(module).t()) {:list, inner_type} -> # Convert to list type quote(do: [unquote(to_elixir_type(inner_type, caller))]) {:map, key_type, value_type} -> # Convert to map type quote( do: %{ unquote(to_elixir_type(key_type, caller)) => unquote(to_elixir_type(value_type, caller)) } ) {:literal, value} -> # For literals, use the value directly case value do v when is_atom(v) -> v v when is_integer(v) -> v v when is_boolean(v) -> v end {:union, types} -> # Convert union to pipe operator [first_type | rest_types] = types first_ast = to_elixir_type(first_type, caller) Enum.reduce(rest_types, first_ast, fn type, acc -> {:|, [], [to_elixir_type(type, caller), acc]} end) {:tuple, types} -> # Convert to tuple type types_ast = Enum.map(types, &to_elixir_type(&1, caller)) {:{}, [], types_ast} {:optional, inner_type} -> # Convert optional to union with nil {:|, [], [to_elixir_type(inner_type, caller), nil]} {:alias, name} -> # For recursive type aliases, use the name with .t() module = String.to_atom(name) quote(do: unquote(module).t()) _ -> # Fallback to any quote(do: any()) end end defp get_field_names(fields) do for {field_name, _} <- fields do String.to_atom(field_name) end end defp get_field_types(fields, caller) do for {field_name, field_type} <- fields do elixir_type = to_elixir_type(field_type, caller) {String.to_atom(field_name), elixir_type} end end defp prepare_opts(opts) do path = opts[:path] || "baml_src" collectors = (opts[:collectors] || []) |> Enum.map(fn collector -> collector.reference end) client_registry = if opts[:client_registry] do opts[:client_registry] else if opts[:llm_client] do %{primary: opts[:llm_client]} else nil end end {path, collectors, client_registry, opts[:tb]} end # If type builder is provided, return as map instead of struct defp parse_result(%{:__baml_class__ => _class_name} = result, prefix, tb) when not is_nil(tb) do Map.new(result, fn {key, value} -> {key, parse_result(value, prefix, tb)} end) end defp parse_result(%{:__baml_class__ => class_name} = result, prefix, tb) do module = Module.concat(prefix, class_name) values = Enum.map(result, fn {key, value} -> {key, parse_result(value, prefix, tb)} end) struct(module, values) end defp parse_result(%{:__baml_enum__ => _, :value => value}, _prefix, _tb) do String.to_atom(value) end defp parse_result(list, prefix, tb) when is_list(list) do Enum.map(list, fn item -> parse_result(item, prefix, tb) end) end defp parse_result(result, _prefix, _tb) do result end defp to_map(args) when is_struct(args) do args |> Map.from_struct() |> to_map() end defp to_map(args) when is_map(args) do Map.new(args, fn {key, value} -> {key, to_map(value)} end) end defp to_map(args) when is_list(args) do Enum.map(args, &to_map/1) end defp to_map(args) do args end end