Packages
llm_composer
0.19.2
0.20.2
0.20.1
0.20.0
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.2
0.18.1
0.18.0
0.17.1
0.17.0
0.16.2
0.16.1
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.1
0.13.0
0.12.3
0.12.2
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.8.0
0.7.0
0.6.0
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
LlmComposer is an Elixir library that facilitates chat interactions with language models, providing tools to handle user messages, generate responses, and execute functions automatically based on model outputs.
Current section
Files
Jump to
Current section
Files
lib/llm_composer/providers/bedrock.ex
if Code.ensure_loaded?(ExAws) do
defmodule LlmComposer.Providers.Bedrock do
@moduledoc """
Provider implementation for Amazon Bedrock.
Handles chat completion requests through Amazon Bedrock Converse API. Any
Bedrock compatible model can be used. To specify any model-specific options for
the request, you can pass them in the `request_params` option and they will
be merged into the base request that is prepared.
"""
@behaviour LlmComposer.Provider
alias LlmComposer.Helpers
alias LlmComposer.Message
alias LlmComposer.ProviderResponse
alias LlmComposer.Providers.Bedrock.StreamOperation
alias LlmComposer.Providers.Utils
@impl LlmComposer.Provider
def name, do: :bedrock
@impl LlmComposer.Provider
@doc """
Reference: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
Reference (stream): https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html
"""
def run(messages, system_message, opts) do
model = Keyword.get(opts, :model)
stream = Keyword.get(opts, :stream_response, false)
if model do
messages
|> build_request(system_message, opts)
|> send_request(model, stream)
|> handle_response(stream)
|> wrap_response(opts)
else
{:error, :model_not_provided}
end
end
@spec build_request(list(Message.t()), Message.t(), keyword()) :: map()
defp build_request(messages, system_message, opts) do
tools =
opts
|> Keyword.get(:functions)
|> Utils.get_tools(name())
tool_config = if tools, do: %{"toolConfig" => %{"tools" => tools}}, else: %{}
base_request =
Map.merge(
%{
"messages" =>
messages
|> Enum.map(&format_message/1)
|> merge_consecutive_tool_results(),
"system" => [format_message(system_message)]
},
tool_config
)
req_params = Keyword.get(opts, :request_params, %{})
base_request
|> Utils.merge_request_params(req_params)
|> maybe_structured_output(opts)
|> Utils.cleanup_body()
end
@spec maybe_structured_output(map(), keyword()) :: map()
defp maybe_structured_output(base_request, opts) do
response_schema = Keyword.get(opts, :response_schema)
if is_map(response_schema) do
Map.put(base_request, "outputConfig", %{
"textFormat" => %{
"type" => "json_schema",
"structure" => %{
"jsonSchema" => %{
"name" => "response",
"schema" => Helpers.json_engine().encode!(response_schema)
}
}
}
})
else
base_request
end
end
@spec send_request(map(), String.t(), boolean()) :: {:ok, term()} | {:error, term()}
defp send_request(payload, model, true) do
opts = Keyword.put(ex_aws_opts(), :http_opts, stream: true)
payload
|> StreamOperation.new(model)
|> ExAws.request(opts)
end
defp send_request(payload, model, false) do
operation = %ExAws.Operation.JSON{
data: payload,
headers: [{"Content-Type", "application/json"}],
http_method: :post,
path: "/model/#{model}/converse",
service: :"bedrock-runtime"
}
ExAws.request(operation, ex_aws_opts())
end
@spec ex_aws_opts() :: keyword()
defp ex_aws_opts do
[service_override: :bedrock, http_client: LlmComposer.Providers.Bedrock.HttpClient]
end
@spec format_message(Message.t()) :: map()
defp format_message(%Message{type: :system, content: content}) do
%{"text" => content}
end
defp format_message(%Message{type: :tool_result, content: content, metadata: metadata}) do
%{
"role" => "user",
"content" => [
%{
"toolResult" => %{
"toolUseId" => metadata["tool_call_id"],
"content" => [%{"text" => to_string(content)}]
}
}
]
}
end
defp format_message(%Message{type: :assistant, metadata: metadata} = msg) do
case get_in(metadata, [:original, "content"]) do
original_content when is_list(original_content) ->
%{"role" => "assistant", "content" => original_content}
_ ->
build_assistant_content(msg)
end
end
defp format_message(%Message{type: role, content: content}) when is_binary(content) do
%{"role" => Atom.to_string(role), "content" => [%{"text" => content}]}
end
defp format_message(%Message{type: role, content: content}) do
%{"role" => Atom.to_string(role), "content" => content}
end
@spec build_assistant_content(Message.t()) :: map()
defp build_assistant_content(%Message{content: content, function_calls: nil})
when is_binary(content) do
%{"role" => "assistant", "content" => [%{"text" => content}]}
end
defp build_assistant_content(%Message{content: content, function_calls: function_calls})
when is_list(function_calls) do
text_parts = if content && content != "", do: [%{"text" => content}], else: []
tool_parts =
Enum.map(function_calls, fn call ->
arguments =
if is_binary(call.arguments) do
Helpers.json_engine().decode!(call.arguments)
else
call.arguments || %{}
end
%{"toolUse" => %{"toolUseId" => call.id, "name" => call.name, "input" => arguments}}
end)
%{"role" => "assistant", "content" => text_parts ++ tool_parts}
end
# Merges consecutive tool-result user messages into a single content block.
# Bedrock requires all toolResult blocks for one assistant turn to be in one user turn.
@spec merge_consecutive_tool_results([map()]) :: [map()]
defp merge_consecutive_tool_results(messages) do
messages
|> Enum.reduce([], fn
%{"role" => "user", "content" => [%{"toolResult" => _} | _] = parts},
[%{"role" => "user", "content" => [%{"toolResult" => _} | _] = prev_parts} | rest] ->
[%{"role" => "user", "content" => prev_parts ++ parts} | rest]
msg, acc ->
[msg | acc]
end)
|> Enum.reverse()
end
@spec handle_response({:ok, term()} | {:error, term()}, boolean()) ::
{:ok, map()} | {:error, term()}
defp handle_response({:ok, chunk_stream}, true) do
# Lazily parse AWS Event Stream frames from the binary chunk stream.
# Frame layout: [4B total_len][4B headers_len][4B prelude_crc][headers...][payload...][4B msg_crc]
# Each extracted payload is a JSON-encoded binary (one per event).
event_stream =
Stream.transform(chunk_stream, <<>>, fn chunk, buffer ->
extract_event_frames(buffer <> chunk, [])
end)
{:ok, %{stream: event_stream}}
end
defp handle_response({:ok, %{"output" => %{"message" => _}} = response}, false) do
{:ok,
%{
response: response,
input_tokens: get_in(response, ["usage", "inputTokens"]),
output_tokens: get_in(response, ["usage", "outputTokens"])
}}
end
defp handle_response({:error, resp}, _stream) do
{:error, resp}
end
defp wrap_response(result, opts) do
result
|> ProviderResponse.Bedrock.new(opts)
|> ProviderResponse.to_llm_response(opts)
end
# Extracts complete AWS Event Stream frames from a binary buffer.
# Returns {payloads, remaining_buffer} for use with Stream.transform.
# Incomplete frames are kept in the buffer for the next chunk.
@spec extract_event_frames(binary(), [binary()]) :: {[binary()], binary()}
defp extract_event_frames(
<<total_len::32-big-unsigned, headers_len::32-big-unsigned, _prelude_crc::32,
rest::binary>> = buffer,
acc
) do
payload_len = total_len - headers_len - 16
if byte_size(rest) >= headers_len + payload_len + 4 do
<<_headers::binary-size(headers_len), payload::binary-size(payload_len), _msg_crc::32,
remaining::binary>> = rest
extract_event_frames(remaining, [payload | acc])
else
{Enum.reverse(acc), buffer}
end
end
defp extract_event_frames(buffer, acc), do: {Enum.reverse(acc), buffer}
end
end