LLMComposer Meetup Demo
Mix.install(
[
{:decimal, "~> 2.3"},
{:finch, "~> 0.21.0"},
{:kino, "~> 0.19"},
{:llm_composer, "~> 0.18.0"},
{:yaml_elixir, "~> 2.12"}
],
config: [
tesla: [disable_deprecated_builder_warning: true],
llm_composer: [
# Keys reading from livebook secrets
open_ai: [api_key: System.get_env("LB_OPENAI_KEY")],
open_router: [api_key: System.get_env("LB_OPENROUTER_KEY")]
]
]
)
Setup Helpers
alias LlmComposer.Providers.OpenAI
alias LlmComposer.Providers.OpenAIResponses
alias LlmComposer.Providers.OpenRouter
alias LlmComposer.Settings
defmodule DemoHelpers do
@json_mod if Code.ensure_loaded?(JSON), do: JSON, else: Jason
def response_summary(res) do
%{
provider: res.provider,
provider_model: res.provider_model,
content: res.main_response && res.main_response.content,
reasoning: res.main_response && res.main_response.reasoning,
input_tokens: res.input_tokens,
output_tokens: res.output_tokens,
reasoning_tokens: res.reasoning_tokens
}
end
def print_response(label, res) do
IO.puts("\n=== #{label} ===")
IO.inspect(response_summary(res), pretty: true)
res
end
def decode_json!(text) do
@json_mod.decode!(text)
end
def print_json(label, json) do
IO.puts("\n=== #{label} ===")
IO.inspect(json, pretty: true)
json
end
def ensure_cache_started do
case Process.whereis(LlmComposer.Cache.Ets) do
nil -> LlmComposer.Cache.Ets.start_link()
pid when is_pid(pid) -> {:ok, pid}
end
end
def print_cost_info(res) do
info = res.cost_info
summary =
if info do
%{
provider: info.provider_name,
model: info.provider_model,
total_tokens: info.total_tokens,
input_cost: Decimal.to_string(info.input_cost, :normal),
output_cost: Decimal.to_string(info.output_cost, :normal),
total_cost: Decimal.to_string(info.total_cost, :normal)
}
else
%{error: "cost_info is nil"}
end
IO.puts("\n=== Cost info ===")
IO.inspect(summary, pretty: true)
res
end
def markdown_render(content, opts \\ []) do
Kino.Markdown.new(content)
end
end
# warning log level
Logger.put_application_level(:llm_composer, :warning)
["LB_OPENAI_KEY", "LB_OPENROUTER_KEY"]
|> Enum.map(fn key -> {key, System.get_env(key)} end)
|> Enum.each(fn
{key, val} when val in [nil, ""] ->
IO.puts("Missing env var #{key}")
_ ->
:ok
end)
1. Minimal Chat
Let’s start with the happy path: one settings struct, one provider, one normalized response.
alias LlmComposer.Settings
basic_settings = %Settings{
providers: [
{OpenAI, [model: "gpt-5.4-mini"]}
# {OpenAI, [model: "gpt-4o-mini"]}
],
system_prompt: "You are a concise assistant for an Elixir meetup."
}
{:ok, basic_res} =
LlmComposer.simple_chat(
basic_settings,
"In one sentence, what problem does LLMComposer solve?"
)
basic_res.main_response
DemoHelpers.markdown_render(basic_res.main_response.content)
2. Same API, but With Turns
The main change is just the message list. The response shape stays normalized.
alias LlmComposer.Message
history = [
Message.new(:user, "I am preparing a LiveView meetup."),
Message.new(:assistant, "Nice. What angle are you presenting?"),
Message.new(:user, "Explain LLMComposer in two short bullets for Elixir developers.")
]
{:ok, history_res} = LlmComposer.run_completion(basic_settings, history)
DemoHelpers.markdown_render(history_res.main_response.content)
3. Manual Tool Calling
The model decides to call a tool, but your app still owns execution.
# A Calculator tool example
defmodule CalculatorTool do
alias LlmComposer.Function
def calculator(%{"expression" => expression}) do
# to match only numbers, spaces and signs
if Regex.match?(~r/^[0-9\.\s\+\-\*\/\(\)]+$/, expression) do
{result, _binding} = Code.eval_string(expression)
result
else
{:error, "invalid expression"}
end
end
def calculator_function do
%Function{
mf: {__MODULE__, :calculator},
name: "calculator",
description: "Evaluate arithmetic expressions",
schema: %{
"type" => "object",
"properties" => %{
"expression" => %{
"type" => "string",
"example" => "2 ** 2 + 2 + 2 * (3 - 1)"
}
},
"additionalProperties" => false,
"required" => ["expression"]
}
}
end
end
:ok
functions = [CalculatorTool.calculator_function()]
model = "gpt-4o-mini"
tool_settings = %Settings{
providers: [
{OpenAI,
[
model: model,
functions: functions,
# optional argument, to make mandatory tool call
tool_choice: "required"
]}
],
system_prompt: """
You are a concise math assistant.
Always use the calculator tool for arithmetic before answering.
"""
}
# user_message = Message.new(:user, "What is (15 + 27) * 2?")
user_message = Message.new(:user, """
Help me know the result of this calculations:
* (15 * 12) + 3
* 11 ** 11
""")
{:ok, first_tool_res} = LlmComposer.run_completion(tool_settings, [user_message])
IO.inspect(first_tool_res.main_response)
first_tool_res.main_response.function_calls
alias LlmComposer.FunctionExecutor
alias LlmComposer.LlmResponse
executed_calls =
first_tool_res
|> LlmResponse.function_calls()
|> Enum.map(fn call ->
{:ok, executed_call} = FunctionExecutor.execute(call, functions)
executed_call
end)
executed_calls
alias LlmComposer.FunctionCallHelpers
tool_messages = FunctionCallHelpers.build_tool_result_messages(executed_calls)
IO.inspect tool_messages, label: "Message object to send to provider: "
assistant_with_tools =
FunctionCallHelpers.build_assistant_with_tools(
OpenAI,
first_tool_res,
user_message,
model: model,
functions: functions
)
messages = [user_message, assistant_with_tools] ++ tool_messages
{:ok, final_tool_res} = LlmComposer.run_completion(tool_settings, messages)
# final_tool_res.main_response.content
DemoHelpers.markdown_render(final_tool_res.main_response.content)
Summary
That’s how llm’s can access the world!
Interesting tools to have when working with AI and LLMs
-
Database querying and interpreting
- connected to a data warehouse for reading
-
API calling and interpreting response
- Doofinder API integration
- Current weather / stock prices / flight status
- Tool that runs code and generates PDFs
-
Email sending
- Slack / Teams notifications
- Calendar scheduling (create/read events)
- Web search and scraping (we also can use provided tools from openai/google/openrouter …)
- File system operations (read CSVs, write reports)
- Running shell commands or Elixir code snippets
4. Structured Outputs
Free text is nice for chat. Structured data is nicer for apps.
# yaml usage for easier reading
ticket_schema = """
type: object
properties:
intent:
type: string
priority:
type: string
reply_needed:
type: boolean
summary:
type: boolean
additionalProperties: false
required: [intent, priority, reply_needed, summary]
"""
structured_settings = %Settings{
providers: [
{OpenRouter,
[
# very cheap model! supports structured outputs, function calls, reasoning...
model: "x-ai/grok-4.1-fast",
response_schema: YamlElixir.read_from_string!(ticket_schema),
# disable reasoning for speed
request_params: %{reasoning: %{enabled: false}}
]}
],
system_prompt: """
Your job is to classify incoming messages based on provided output json schema.
"""
}
ticket_prompt = """
Classify this message:
"We rolled out your package for the meetup, but the HDMI adapter
is missing and the talk starts in 20 minutes."
"""
{:ok, structured_res} = LlmComposer.simple_chat(structured_settings, ticket_prompt)
structured_json = DemoHelpers.decode_json!(structured_res.main_response.content)
DemoHelpers.print_response("Structured output raw response", structured_res)
DemoHelpers.print_json("Structured output parsed JSON", structured_json)
5. OpenAI Responses API
Same normalized response, but richer upstream semantics like reasoning summaries and reasoning tokens.
responses_settings = %Settings{
providers: [
{OpenAIResponses,
[
model: "gpt-5.4-mini",
# simpler reasoning effort usage
# reasoning_effort: "high",
# using request_params instead of reasoning_effort to allow summary parameter usage
request_params: %{
reasoning: %{
effort: "high",
summary: "detailed"
}
}
]}
],
system_prompt: "You are a concise assistant for senior Elixir developers."
}
{:ok, responses_res} =
LlmComposer.simple_chat(
responses_settings,
"Give me three short bullets on why LiveView works well for AI features."
)
# DemoHelpers.print_response("Responses API", responses_res)
# IO.inspect(responses_res)
to_render = """
## Reasoning
#{responses_res.main_response.reasoning}
## Response
#{responses_res.main_response.content}
"""
DemoHelpers.markdown_render(to_render)
6. Cost Tracking
This is the production-oriented closer: same request flow, but now you can inspect token usage and cost.
{:ok, _cache} = DemoHelpers.ensure_cache_started()
cost_settings = %Settings{
providers: [
{OpenAI,
[
model: "gpt-4o-mini",
track_costs: true
]}
],
system_prompt: "You are a concise assistant."
}
{:ok, cost_res} =
LlmComposer.simple_chat(
cost_settings,
"Explain in one paragraph why cost tracking matters in production LLM apps."
)
# DemoHelpers.print_response("Cost tracking response", cost_res)
# DemoHelpers.print_cost_info(cost_res)
IO.inspect cost_res.cost_info
DemoHelpers.markdown_render(cost_res.main_response.content)
7. Streaming
# minimal Finch setup
Application.put_env(:llm_composer, :tesla_adapter, {Tesla.Adapter.Finch, name: MeetupFinch})
:ok =
case Finch.start_link(name: MeetupFinch) do
{:ok, _finch} -> :ok
{:error, {:already_started, _pid}} -> :ok
end
alias LlmComposer.Providers.Ollama
streaming_settings = %Settings{
providers: [
{OpenAI, [model: "gpt-4o-mini"]}
# local ollama with very small model gemma3 1b
# {Ollama, [model: "gemma3:1b"]}
],
system_prompt: "You are a concise assistant.",
stream_response: true
}
{:ok, streaming_res} =
LlmComposer.simple_chat(
streaming_settings,
"Write four short bullets about what makes a good meetup demo."
)
streaming_res.stream
|> LlmComposer.parse_stream_response(streaming_res.provider)
|> Enum.each(fn chunk ->
if chunk.type == :text_delta and is_binary(chunk.text) do
IO.write(chunk.text)
end
end)
8. Provider fallback
alias LlmComposer.ProviderRouter
:ok =
case ProviderRouter.Simple.start_link([]) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
end
alias LlmComposer.Providers.Ollama
fallback_settings = %Settings{
providers: [
# just forcing an error, wrong url
{Ollama, [model: "gemma3:1b", url: "http://localhost:9999"]},
{OpenAI, [model: "gpt-4o-mini"]}
],
system_prompt: "You are a concise assistant."
}
{:ok, res} = LlmComposer.simple_chat(fallback_settings, "In one sentence, what is Elixir?")
IO.puts("Answered by: #{res.provider}")
DemoHelpers.markdown_render(res.main_response.content)
If you need to cut time live, keep this order:
- Minimal chat
- Tool calling
- Structured outputs
- Responses API
- Cost tracking
The app demo can then own the more visual streaming experience.