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
2 changes: 2 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"id":"req_llm-1qg","title":"Eliminate unsafe runtime String.to_atom conversions","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-23T21:23:22.503218-06:00","created_by":"mhostetler","updated_at":"2026-02-23T21:23:25.53159-06:00","closed_at":"2026-02-23T21:23:25.53159-06:00","close_reason":"Completed"}
{"id":"req_llm-97o","title":"Fix nested tool-call map key normalization for string-key inputs","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-23T21:11:16.623904-06:00","created_by":"mhostetler","updated_at":"2026-02-23T21:12:07.503668-06:00","closed_at":"2026-02-23T21:12:07.503668-06:00","close_reason":"Completed"}
13 changes: 10 additions & 3 deletions lib/req_llm/provider/defaults.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1339,9 +1339,7 @@ defmodule ReqLLM.Provider.Defaults do
{model_struct.provider, model_struct, model_struct.model}

model_name when is_binary(model_name) ->
provider_id =
String.split(model_name, ":", parts: 2) |> List.first() |> String.to_atom()

provider_id = provider_id_from_model_name(model_name)
model = %LLMDB.Model{id: model_name, provider: provider_id}
{provider_id, model, model_name}
end
Expand Down Expand Up @@ -1410,6 +1408,15 @@ defmodule ReqLLM.Provider.Defaults do
{req, %{resp | body: merged_response}}
end

defp provider_id_from_model_name(model_name) do
provider_part = String.split(model_name, ":", parts: 2) |> List.first()

ReqLLM.Providers.list()
|> Enum.find(:unknown, fn provider ->
Atom.to_string(provider) == provider_part
end)
end

defp extract_and_set_object(response, req) do
provider_opts = req.options[:provider_options] || []
response_format = provider_opts[:response_format]
Expand Down
22 changes: 15 additions & 7 deletions lib/req_llm/providers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,21 @@ defmodule ReqLLM.Providers do
if function_exported?(module, :provider_id, 0) do
{:ok, module.provider_id()}
else
module
|> Atom.to_string()
|> String.split(".")
|> List.last()
|> String.downcase()
|> String.to_atom()
|> then(&{:ok, &1})
module_name =
module
|> Atom.to_string()
|> String.split(".")
|> List.last()
|> String.downcase()

provider_id =
try do
String.to_existing_atom(module_name)
rescue
ArgumentError -> module
end

{:ok, provider_id}
end
rescue
_ -> :error
Expand Down
8 changes: 7 additions & 1 deletion lib/req_llm/providers/amazon_bedrock/converse.ex
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ defmodule ReqLLM.Providers.AmazonBedrock.Converse do
defp parse_message(nil), do: nil

defp parse_message(message_data) do
role = String.to_atom(message_data["role"])
role = parse_role(message_data["role"])
content_blocks = message_data["content"] || []

# Separate tool calls from regular content
Expand All @@ -665,6 +665,12 @@ defmodule ReqLLM.Providers.AmazonBedrock.Converse do
end
end

defp parse_role("user"), do: :user
defp parse_role("assistant"), do: :assistant
defp parse_role("system"), do: :system
defp parse_role("tool"), do: :tool
defp parse_role(_), do: :assistant

# Parse content and separate tool calls from regular content
defp parse_content_with_tool_calls(content_blocks) when is_list(content_blocks) do
Enum.reduce(content_blocks, {[], []}, fn block, {tool_calls, content_parts} ->
Expand Down
28 changes: 25 additions & 3 deletions lib/req_llm/streaming/fixtures.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ defmodule ReqLLM.Streaming.Fixtures do

@type t :: %__MODULE__{
url: String.t(),
method: :get | :post | :put | :patch | :delete,
method: :get | :post | :put | :patch | :delete | :head | :options | :unknown,
req_headers: map(),
status: integer() | nil,
resp_headers: map() | nil
Expand All @@ -30,7 +30,11 @@ defmodule ReqLLM.Streaming.Fixtures do
@doc """
Creates a new HTTPContext from request parameters.
"""
@spec new(String.t(), :get | :post | :put | :patch | :delete, map()) :: t()
@spec new(
String.t(),
:get | :post | :put | :patch | :delete | :head | :options | :unknown,
map()
) :: t()
def new(url, method, headers) do
%__MODULE__{
url: url,
Expand Down Expand Up @@ -64,11 +68,29 @@ defmodule ReqLLM.Streaming.Fixtures do
"#{finch_request.scheme}://#{finch_request.host}:#{finch_request.port}#{finch_request.path}"
end

method = String.downcase(finch_request.method) |> String.to_atom()
method = normalize_http_method(finch_request.method)

new(url, method, Map.new(finch_request.headers))
end

defp normalize_http_method(method) when is_binary(method) do
case String.downcase(method) do
"get" -> :get
"post" -> :post
"put" -> :put
"patch" -> :patch
"delete" -> :delete
"head" -> :head
"options" -> :options
_ -> :unknown
end
end

defp normalize_http_method(method) when is_atom(method),
do: normalize_http_method(Atom.to_string(method))

defp normalize_http_method(_), do: :unknown

defp sanitize_headers(headers) when is_map(headers) do
sensitive_keys = [
"authorization",
Expand Down
197 changes: 188 additions & 9 deletions lib/req_llm/tool.ex
Original file line number Diff line number Diff line change
Expand Up @@ -427,24 +427,203 @@ defmodule ReqLLM.Tool do

defp normalize_input_keys(input, parameter_schema)
when is_map(input) and is_list(parameter_schema) do
schema_key_map =
schema_entries =
parameter_schema
|> Enum.map(fn {key, _opts} -> {Atom.to_string(key), key} end)
|> Enum.filter(fn
{key, opts} when is_atom(key) and is_list(opts) -> true
_ -> false
end)
|> Map.new()

Map.new(input, fn
schema_key_map =
schema_entries
|> Map.keys()
|> Map.new(fn key -> {Atom.to_string(key), key} end)

Map.new(input, fn {key, value} ->
{normalized_key, field_opts} =
normalize_key_and_field_opts(key, schema_key_map, schema_entries)

{normalized_key, normalize_typed_value(value, field_opts)}
end)
end

defp normalize_input_keys(input, _parameter_schema), do: input

defp normalize_key_and_field_opts(key, schema_key_map, schema_entries) when is_binary(key) do
case Map.fetch(schema_key_map, key) do
{:ok, atom_key} -> {atom_key, Map.get(schema_entries, atom_key)}
:error -> {key, nil}
end
end

defp normalize_key_and_field_opts(key, _schema_key_map, schema_entries) when is_atom(key) do
{key, Map.get(schema_entries, key)}
end

defp normalize_key_and_field_opts(key, _schema_key_map, _schema_entries), do: {key, nil}

defp normalize_typed_value(value, opts) when is_list(opts) do
normalize_typed_value(value, Keyword.get(opts, :type), opts)
end

defp normalize_typed_value(value, _opts), do: value

defp normalize_typed_value(value, :map, opts) when is_map(value) do
case nested_map_schema(opts) do
schema when is_list(schema) -> normalize_input_keys(value, schema)
_ -> normalize_existing_atom_map(value)
end
end

defp normalize_typed_value(value, {:map, schema}, _opts)
when is_map(value) and is_list(schema) do
normalize_input_keys(value, schema)
end

defp normalize_typed_value(value, {:map, key_type, value_type}, _opts) when is_map(value) do
normalize_typed_map(value, key_type, value_type)
end

defp normalize_typed_value(value, {:list, :map}, opts) when is_list(value) do
case nested_map_schema(opts) do
schema when is_list(schema) ->
Enum.map(value, &normalize_map_with_schema(&1, schema))

_ ->
Enum.map(value, &normalize_list_item(&1, :map))
end
end

defp normalize_typed_value(value, {:list, {:map, schema}}, _opts)
when is_list(value) and is_list(schema) do
Enum.map(value, &normalize_map_with_schema(&1, schema))
end

defp normalize_typed_value(value, {:list, inner_type}, _opts) when is_list(value) do
Enum.map(value, &normalize_list_item(&1, inner_type))
end

defp normalize_typed_value(value, {:or, subtypes}, opts) when is_list(subtypes) do
Enum.reduce(subtypes, value, fn subtype, acc ->
normalize_typed_value(acc, subtype, opts)
end)
end

defp normalize_typed_value(value, {:tuple, subtypes}, _opts)
when is_tuple(value) and is_list(subtypes) do
tuple_items = Tuple.to_list(value)

tuple_items
|> Enum.with_index()
|> Enum.map(fn {item, idx} ->
case Enum.fetch(subtypes, idx) do
{:ok, subtype} -> normalize_list_item(item, subtype)
:error -> item
end
end)
|> List.to_tuple()
end

defp normalize_typed_value(value, _type, _opts), do: value

defp normalize_map_with_schema(value, schema) when is_map(value) do
normalize_input_keys(value, schema)
end

defp normalize_map_with_schema(value, _schema), do: value

defp normalize_list_item(value, :map) when is_map(value), do: normalize_existing_atom_map(value)

defp normalize_list_item(value, {:map, schema}) when is_map(value) and is_list(schema) do
normalize_input_keys(value, schema)
end

defp normalize_list_item(value, {:map, key_type, value_type}) when is_map(value) do
normalize_typed_map(value, key_type, value_type)
end

defp normalize_list_item(value, {:list, inner_type}) when is_list(value) do
Enum.map(value, &normalize_list_item(&1, inner_type))
end

defp normalize_list_item(value, {:or, subtypes}) when is_list(subtypes) do
Enum.reduce(subtypes, value, fn subtype, acc ->
normalize_list_item(acc, subtype)
end)
end

defp normalize_list_item(value, {:tuple, subtypes})
when is_tuple(value) and is_list(subtypes) do
tuple_items = Tuple.to_list(value)

tuple_items
|> Enum.with_index()
|> Enum.map(fn {item, idx} ->
case Enum.fetch(subtypes, idx) do
{:ok, subtype} -> normalize_list_item(item, subtype)
:error -> item
end
end)
|> List.to_tuple()
end

defp normalize_list_item(value, _type), do: value

defp normalize_typed_map(map, :atom, value_type) do
Map.new(map, fn
{key, value} when is_binary(key) ->
case Map.fetch(schema_key_map, key) do
{:ok, atom_key} -> {atom_key, value}
:error -> {key, value}
end
{to_existing_atom_or_original(key), normalize_list_item(value, value_type)}

{key, value} ->
{key, value}
{key, normalize_list_item(value, value_type)}
end)
end

defp normalize_input_keys(input, _parameter_schema), do: input
defp normalize_typed_map(map, _key_type, value_type) do
Map.new(map, fn {key, value} ->
{key, normalize_list_item(value, value_type)}
end)
end

defp normalize_existing_atom_map(map) do
Map.new(map, fn
{key, value} when is_binary(key) ->
{to_existing_atom_or_original(key), normalize_existing_atom_value(value)}

{key, value} ->
{key, normalize_existing_atom_value(value)}
end)
end

defp normalize_existing_atom_value(value) when is_map(value),
do: normalize_existing_atom_map(value)

defp normalize_existing_atom_value(value) when is_list(value),
do: Enum.map(value, &normalize_existing_atom_value/1)

defp normalize_existing_atom_value(value), do: value

defp nested_map_schema(opts) do
case Keyword.get(opts, :keys) do
schema when is_list(schema) ->
schema

_ ->
case Keyword.get(opts, :properties) do
schema when is_list(schema) -> schema
_ -> nil
end
end
end

defp to_existing_atom_or_original(key) do
try do
String.to_existing_atom(key)
rescue
ArgumentError -> key
end
end

defp call_callback({module, function}, input) do
apply(module, function, [input])
Expand Down
36 changes: 36 additions & 0 deletions test/req_llm/provider/defaults_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -742,4 +742,40 @@ defmodule ReqLLM.Provider.DefaultsTest do
assert length(response.message.tool_calls) == 1
end
end

describe "default_decode_response/1" do
test "handles unknown provider prefix in model string without atomizing" do
req =
Req.new()
|> Map.update!(:options, fn opts ->
Map.merge(opts, %{
operation: :chat,
model: "unknown_provider:model-1",
context: %Context{messages: []}
})
end)

resp = %Req.Response{
status: 200,
body: %{
"id" => "chatcmpl-unknown",
"model" => "unknown_provider:model-1",
"choices" => [
%{
"message" => %{"role" => "assistant", "content" => "Hello"},
"finish_reason" => "stop"
}
],
"usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}
}
}

{returned_req, returned_resp} = Defaults.default_decode_response({req, resp})

assert returned_req == req
assert %ReqLLM.Response{} = returned_resp.body
assert returned_resp.body.model == "unknown_provider:model-1"
assert returned_resp.body.finish_reason == :stop
end
end
end
Loading