Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.20.2] - 2026-07-08

### Added

- Added `:structured_output_strategy` option to the Bedrock provider (`:native` default, `:tool_use` opt-in). `:tool_use` requests `:response_schema` output by forcing the model to call a synthesized `structured_response` tool, working on Bedrock models without native structured-output support (Mistral, DeepSeek, older Qwen/Llama, etc.).

## [0.20.1] - 2026-07-01

### Added
Expand Down Expand Up @@ -333,6 +339,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Initial release with support for basic message handling, interaction with OpenAI and Ollama models, and a foundational structure for model settings and function execution.

---
[0.20.2]: https://github.com/doofinder/llm_composer/compare/0.20.1...0.20.2
[0.20.1]: https://github.com/doofinder/llm_composer/compare/0.20.0...0.20.1
[0.20.0]: https://github.com/doofinder/llm_composer/compare/0.19.6...0.20.0
[0.19.6]: https://github.com/doofinder/llm_composer/compare/0.19.5...0.19.6
Expand Down
25 changes: 23 additions & 2 deletions lib/llm_composer/provider_response/parser/bedrock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ defmodule LlmComposer.ProviderResponse.Parser.Bedrock do
@moduledoc false

alias LlmComposer.Cost.CostAssembler
alias LlmComposer.FunctionCall
alias LlmComposer.FunctionCallExtractors
alias LlmComposer.LlmResponse
alias LlmComposer.Message

@structured_response_tool_name "structured_response"

@spec parse({:ok | :error, any()}, atom(), keyword()) ::
{:ok, LlmResponse.t()} | {:error, term()}
def parse({:error, resp}, _provider, _opts), do: {:error, resp}
Expand All @@ -24,8 +27,10 @@ defmodule LlmComposer.ProviderResponse.Parser.Bedrock do
content = response["output"]["message"]["content"]
role = String.to_existing_atom(response["output"]["message"]["role"])

message_content = extract_text(content)
function_calls = FunctionCallExtractors.from_bedrock_content(content)
{message_content, function_calls} =
content
|> FunctionCallExtractors.from_bedrock_content()
|> extract_content(content, opts)

message = %{
Message.new(role, message_content, %{original: response["output"]["message"]})
Expand Down Expand Up @@ -55,6 +60,22 @@ defmodule LlmComposer.ProviderResponse.Parser.Bedrock do
}}
end

# When falling back to forced tool-use for structured output, the schema-conforming
# JSON lives in the synthesized tool call's arguments rather than in a text block.
@spec extract_content([FunctionCall.t()] | nil, list(), keyword()) ::
{String.t() | nil, [FunctionCall.t()] | nil}
defp extract_content(function_calls, content, opts) do
with true <- is_map(Keyword.get(opts, :response_schema)),
true <- Keyword.get(opts, :structured_output_strategy) == :tool_use,
%FunctionCall{} = structured_call <-
Enum.find(function_calls || [], &(&1.name == @structured_response_tool_name)) do
remaining = Enum.reject(function_calls, &(&1.name == @structured_response_tool_name))
{structured_call.arguments, if(remaining == [], do: nil, else: remaining)}
else
_ -> {extract_text(content), function_calls}
end
end

@spec extract_text(list()) :: String.t() | nil
defp extract_text(content) when is_list(content) do
joined =
Expand Down
113 changes: 80 additions & 33 deletions lib/llm_composer/providers/bedrock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ if Code.ensure_loaded?(ExAws) do

The timeout applies to all Mint-based requests (streaming and non-streaming).
Finch regular (non-streaming) requests use Finch's own timeout configuration.

## Structured outputs

`:response_schema` requests JSON output matching a schema. `:structured_output_strategy`
picks how that's requested from Bedrock:

* `:native` (default) - uses `outputConfig.textFormat.json_schema`. Only supported by
some models (e.g. newer Anthropic Claude models on Bedrock or Nova models).
* `:tool_use` - forces the model to call a synthesized `structured_response` tool whose
input schema is `:response_schema`, then unwraps that call's arguments into the
response content. Works on models without native structured-output support (Mistral,
DeepSeek, older Qwen/Llama, etc.), since tool calling is supported far more
broadly than `outputConfig` across Bedrock's model vendors. Note that this forces
`toolChoice: {"any": {}}`, so combining it with `:functions` means the model could
call one of those tools instead of `structured_response`.
"""
@behaviour LlmComposer.Provider

Expand All @@ -32,6 +47,8 @@ if Code.ensure_loaded?(ExAws) do
alias LlmComposer.Providers.Bedrock.StreamOperation
alias LlmComposer.Providers.Utils

@structured_response_tool_name "structured_response"

@impl LlmComposer.Provider
def name, do: :bedrock

Expand All @@ -57,52 +74,82 @@ if Code.ensure_loaded?(ExAws) do

@spec build_request(list(Message.t()), Message.t(), keyword()) :: map()
defp build_request(messages, system_message, opts) do
base_request = %{
"messages" =>
messages
|> Enum.map(&format_message/1)
|> merge_consecutive_tool_results(),
"system" => [format_message(system_message)]
}

req_params = Keyword.get(opts, :request_params, %{})

base_request
|> Utils.merge_request_params(req_params)
|> put_tool_config(opts)
|> Utils.cleanup_body()
end

@spec put_tool_config(map(), keyword()) :: map()
defp put_tool_config(base_request, opts) do
tools =
opts
|> Keyword.get(:functions)
|> Utils.get_tools(name())

tool_config = if tools, do: %{"toolConfig" => %{"tools" => tools}}, else: %{}
response_schema = Keyword.get(opts, :response_schema)
strategy = Keyword.get(opts, :structured_output_strategy, :native)

base_request =
Map.merge(
%{
"messages" =>
messages
|> Enum.map(&format_message/1)
|> merge_consecutive_tool_results(),
"system" => [format_message(system_message)]
},
tool_config
)
case {is_map(response_schema), strategy} do
{true, :tool_use} ->
put_tool_use_structured_output(base_request, response_schema, tools)

req_params = Keyword.get(opts, :request_params, %{})
{true, :native} ->
base_request
|> maybe_put_tools(tools)
|> put_native_structured_output(response_schema)

base_request
|> Utils.merge_request_params(req_params)
|> maybe_structured_output(opts)
|> Utils.cleanup_body()
_ ->
maybe_put_tools(base_request, tools)
end
end

@spec maybe_structured_output(map(), keyword()) :: map()
defp maybe_structured_output(base_request, opts) do
response_schema = Keyword.get(opts, :response_schema)
@spec maybe_put_tools(map(), [map()] | nil) :: map()
defp maybe_put_tools(base_request, nil), do: base_request

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)
}
defp maybe_put_tools(base_request, tools) do
Map.put(base_request, "toolConfig", %{"tools" => tools})
end

@spec put_native_structured_output(map(), map()) :: map()
defp put_native_structured_output(base_request, 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 put_tool_use_structured_output(map(), map(), [map()] | nil) :: map()
defp put_tool_use_structured_output(base_request, response_schema, tools) do
structured_tool = %{
"toolSpec" => %{
"name" => @structured_response_tool_name,
"description" => "Return the structured response matching the required schema.",
"inputSchema" => %{"json" => response_schema}
}
}

Map.put(base_request, "toolConfig", %{
"tools" => [structured_tool | tools || []],
"toolChoice" => %{"any" => %{}}
})
end

@spec send_request(map(), String.t(), boolean()) :: {:ok, term()} | {:error, term()}
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule LlmComposer.MixProject do
def project do
[
app: :llm_composer,
version: "0.20.1",
version: "0.20.2",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps(),
Expand Down
116 changes: 116 additions & 0 deletions test/llm_composer/provider_response/parser/bedrock_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
defmodule LlmComposer.ProviderResponse.Parser.BedrockTest do
use ExUnit.Case, async: true

alias LlmComposer.ProviderResponse.Parser.Bedrock

# Guarantees :assistant exists as an atom regardless of test run/load order,
# since `parse/3` derives it from a string via `String.to_existing_atom/1`.
setup_all do
_ = :assistant
:ok
end

describe "parse/3 with :tool_use structured output strategy" do
test "unwraps the structured_response tool call into main_response content" do
response = %{
"output" => %{
"message" => %{
"role" => "assistant",
"content" => [
%{
"toolUse" => %{
"toolUseId" => "call_1",
"name" => "structured_response",
"input" => %{"answer" => "42"}
}
}
]
}
},
"usage" => %{"inputTokens" => 10, "outputTokens" => 5}
}

opts = [response_schema: %{"type" => "object"}, structured_output_strategy: :tool_use]

assert {:ok, llm_response} = Bedrock.parse({:ok, %{response: response}}, :bedrock, opts)
assert llm_response.main_response.content == ~s({"answer":"42"})
assert llm_response.main_response.function_calls == nil
end

test "leaves other tool calls untouched when structured_response is not called" do
response = %{
"output" => %{
"message" => %{
"role" => "assistant",
"content" => [
%{
"toolUse" => %{
"toolUseId" => "call_1",
"name" => "some_other_tool",
"input" => %{"foo" => "bar"}
}
}
]
}
},
"usage" => %{"inputTokens" => 10, "outputTokens" => 5}
}

opts = [response_schema: %{"type" => "object"}, structured_output_strategy: :tool_use]

assert {:ok, llm_response} = Bedrock.parse({:ok, %{response: response}}, :bedrock, opts)
assert llm_response.main_response.content == nil
assert [%{name: "some_other_tool"}] = llm_response.main_response.function_calls
end

test "keeps remaining tool calls alongside the unwrapped structured response" do
response = %{
"output" => %{
"message" => %{
"role" => "assistant",
"content" => [
%{
"toolUse" => %{
"toolUseId" => "call_1",
"name" => "some_other_tool",
"input" => %{"foo" => "bar"}
}
},
%{
"toolUse" => %{
"toolUseId" => "call_2",
"name" => "structured_response",
"input" => %{"answer" => "42"}
}
}
]
}
},
"usage" => %{"inputTokens" => 10, "outputTokens" => 5}
}

opts = [response_schema: %{"type" => "object"}, structured_output_strategy: :tool_use]

assert {:ok, llm_response} = Bedrock.parse({:ok, %{response: response}}, :bedrock, opts)
assert llm_response.main_response.content == ~s({"answer":"42"})
assert [%{name: "some_other_tool"}] = llm_response.main_response.function_calls
end

test "falls back to plain text extraction without the tool_use strategy" do
response = %{
"output" => %{
"message" => %{
"role" => "assistant",
"content" => [%{"text" => "hello"}]
}
},
"usage" => %{"inputTokens" => 10, "outputTokens" => 5}
}

opts = [response_schema: %{"type" => "object"}]

assert {:ok, llm_response} = Bedrock.parse({:ok, %{response: response}}, :bedrock, opts)
assert llm_response.main_response.content == "hello"
end
end
end
Loading