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
106 changes: 105 additions & 1 deletion lib/req_llm/model_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ defmodule ReqLLM.ModelHelpers do
{:chat?, [:chat]}
]

@bedrock_inference_prefixes ~w(us eu ap apac ca au jp us-gov global)
@bedrock_inference_prefixes ~w(us eu ap apac ca au jp in us-gov global)

@anthropic_thinking_off_types ["disabled", "between_tools"]

for {function_name, path} <- @capability_checks do
path_str = Enum.map_join(path, ".", &to_string/1)
Expand Down Expand Up @@ -135,6 +137,108 @@ defmodule ReqLLM.ModelHelpers do
end
end

@doc """
Returns the thinking types a Claude model accepts, such as `"adaptive"`,
`"enabled"`, `"disabled"` or `"between_tools"`.

The model's own reasoning metadata is read first. Hosted Claude models on
Amazon Bedrock, Google Vertex AI and Azure often carry no thinking metadata,
so the native Anthropic model is consulted when the hosted entry has none.
Returns `[]` when no metadata is available.

## Examples

iex> model = %LLMDB.Model{provider: :anthropic, id: "claude-example", capabilities: %{reasoning: %{thinking: %{types: ["adaptive", "between_tools"]}}}}
iex> ReqLLM.ModelHelpers.anthropic_thinking_types(model)
["adaptive", "between_tools"]
"""
@spec anthropic_thinking_types(LLMDB.Model.t() | term()) :: [String.t()]
def anthropic_thinking_types(%LLMDB.Model{} = model) do
model_thinking_types(model) || hosted_anthropic_thinking_types(model) || []
end

def anthropic_thinking_types(_), do: []

@doc """
Returns the thinking type that turns thinking off for a Claude model, or `nil`
when the model cannot turn thinking off or its thinking types are unknown.

`"disabled"` is preferred where the model accepts it. Claude Sonnet 5.5 turns
thinking off with `"between_tools"` instead, and Claude Opus 5.5 cannot turn
it off at all.

## Examples

iex> model = %LLMDB.Model{provider: :anthropic, id: "claude-example", capabilities: %{reasoning: %{thinking: %{types: ["adaptive", "between_tools"]}}}}
iex> ReqLLM.ModelHelpers.anthropic_thinking_off_type(model)
"between_tools"

iex> model = %LLMDB.Model{provider: :anthropic, id: "claude-example", capabilities: %{reasoning: %{thinking: %{types: ["adaptive"]}}}}
iex> ReqLLM.ModelHelpers.anthropic_thinking_off_type(model)
nil
"""
@spec anthropic_thinking_off_type(LLMDB.Model.t() | term()) :: String.t() | nil
def anthropic_thinking_off_type(model) do
types = anthropic_thinking_types(model)
Enum.find(@anthropic_thinking_off_types, &(&1 in types))
end

@doc """
Whether an Anthropic thinking config turns thinking off, with either
`type: "disabled"` or `type: "between_tools"`.

## Examples

iex> ReqLLM.ModelHelpers.anthropic_thinking_off?(%{type: "between_tools"})
true

iex> ReqLLM.ModelHelpers.anthropic_thinking_off?(%{"type" => "adaptive"})
false
"""
@spec anthropic_thinking_off?(term()) :: boolean()
def anthropic_thinking_off?(thinking) when is_map(thinking) do
normalize_thinking_type(nested_value(thinking, [:type])) in @anthropic_thinking_off_types
end

def anthropic_thinking_off?(_thinking), do: false

defp model_thinking_types(%LLMDB.Model{} = model) do
[
nested_value(model.capabilities, [:reasoning, :thinking, :types]),
nested_value(model.extra, [:provider_capabilities, :thinking, :types]),
nested_value(model.extra, [:capabilities, :thinking, :types])
]
|> Enum.find_value(&thinking_type_names/1)
end

defp hosted_anthropic_thinking_types(model) do
model
|> hosted_anthropic_model_ids()
|> Enum.find_value(fn id ->
case LLMDB.model(:anthropic, id) do
{:ok, %LLMDB.Model{} = native} -> model_thinking_types(native)
_ -> nil
end
end)
end

defp thinking_type_names([_ | _] = types) do
types
|> Enum.map(&normalize_thinking_type/1)
|> Enum.reject(&is_nil/1)
end

defp thinking_type_names(types) when is_map(types) and map_size(types) > 0 do
types
|> Map.keys()
|> Enum.filter(&(thinking_type_supported?(types, &1) == true))
|> Enum.map(&normalize_thinking_type/1)
|> Enum.reject(&is_nil/1)
|> Enum.sort()
end

defp thinking_type_names(_types), do: nil

defp model_adaptive_thinking_required?(%LLMDB.Model{} = model) do
adaptive_thinking_types_required?(
nested_value(model.capabilities, [:reasoning, :thinking, :types])
Expand Down
14 changes: 5 additions & 9 deletions lib/req_llm/providers/amazon_bedrock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -838,15 +838,11 @@ defmodule ReqLLM.Providers.AmazonBedrock do
# Note: pre_validate_options is not yet a formal Provider callback
# It's called by Options.process/4 if the provider exports it
def pre_validate_options(operation, model, opts) do
# Handle reasoning parameters for Claude models on Bedrock
opts =
if ModelHelpers.adaptive_thinking_required?(model) do
PlatformReasoning.prepare_adaptive_options(operation, model, opts)
else
maybe_translate_reasoning_params(model, opts)
end

{opts, []}
if ModelHelpers.adaptive_thinking_required?(model) do
PlatformReasoning.prepare_adaptive_options(operation, model, opts)
else
{maybe_translate_reasoning_params(model, opts), []}
end
end

# Translate reasoning_effort/reasoning_token_budget to Bedrock additionalModelRequestFields
Expand Down
126 changes: 120 additions & 6 deletions lib/req_llm/providers/anthropic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ defmodule ReqLLM.Providers.Anthropic do
temperature top_p stop_sequences thinking output_config
)a

@sampling_parameters [:temperature, :top_p, :top_k, :anthropic_top_k]

@unsupported_parameters ~w(
presence_penalty frequency_penalty logprobs top_logprobs response_format
)a
Expand Down Expand Up @@ -876,7 +878,6 @@ defmodule ReqLLM.Providers.Anthropic do

@impl ReqLLM.Provider
def translate_options(operation, model, opts) do
# Anthropic-specific parameter translation
translated_opts =
opts
|> translate_stop_parameter()
Expand All @@ -887,7 +888,11 @@ defmodule ReqLLM.Providers.Anthropic do
|> remove_conflicting_sampling_params()
|> translate_unsupported_parameters()

{translated_opts, []}
warnings =
thinking_off_warnings(opts, model) ++
sampling_parameter_warnings(opts, translated_opts, model)

{translated_opts, warnings}
end

@impl ReqLLM.Provider
Expand Down Expand Up @@ -1459,7 +1464,7 @@ defmodule ReqLLM.Providers.Anthropic do

case reasoning_effort do
:none ->
opts
put_thinking_off(opts, model)

:minimal ->
put_reasoning_effort(opts, model, :minimal, reasoning_budget)
Expand Down Expand Up @@ -1487,6 +1492,96 @@ defmodule ReqLLM.Providers.Anthropic do
end
end

defp put_thinking_off(opts, model) do
case adaptive_thinking_off_type(model) do
nil -> opts
type -> Keyword.put(opts, :thinking, %{type: type})
end
end

defp adaptive_thinking_off_type(model) do
if ReqLLM.ModelHelpers.adaptive_thinking_required?(model) do
ReqLLM.ModelHelpers.anthropic_thinking_off_type(model)
end
end

defp thinking_off_warnings(opts, model) do
explicit_thinking = Keyword.get(opts, :thinking)

cond do
not thinking_off_unavailable?(model) ->
[]

ReqLLM.ModelHelpers.anthropic_thinking_off?(explicit_thinking) ->
[
"#{model_name(model)} cannot turn thinking off and does not accept thinking type " <>
"#{inspect(ReqLLM.MapAccess.get(explicit_thinking, :type))}. " <>
"Use a lower reasoning_effort, such as :low, to reduce thinking."
]

Keyword.get(opts, :reasoning_effort) == :none ->
[
"#{model_name(model)} cannot turn thinking off, so reasoning_effort: :none was not applied " <>
"and the model thinks at its default effort. " <>
"Use a lower reasoning_effort, such as :low, to reduce thinking."
]

true ->
[]
end
end

defp thinking_off_unavailable?(model) do
ReqLLM.ModelHelpers.adaptive_thinking_required?(model) and
ReqLLM.ModelHelpers.anthropic_thinking_types(model) != [] and
is_nil(ReqLLM.ModelHelpers.anthropic_thinking_off_type(model))
end

defp sampling_parameter_warnings(opts, translated_opts, model) do
Enum.flat_map(@sampling_parameters, fn key ->
before = Keyword.get(opts, key)
after_translation = Keyword.get(translated_opts, key)

cond do
is_nil(before) -> []
is_nil(after_translation) -> [sampling_drop_warning(key, translated_opts, model)]
after_translation != before -> [sampling_change_warning(key, before, after_translation)]
true -> []
end
end)
end

defp sampling_drop_warning(key, translated_opts, model) do
cond do
sampling_unsupported?(model) ->
"#{model_name(model)} does not accept #{inspect(key)}, so it was dropped from the request."

thinking_on?(Keyword.get(translated_opts, :thinking)) ->
"#{inspect(key)} is not compatible with extended thinking on #{model_name(model)}, " <>
"so it was dropped from the request."

key == :top_p ->
":top_p was dropped because :temperature is also set, and #{model_name(model)} " <>
"accepts only one of them."

true ->
"#{inspect(key)} was dropped from the request to #{model_name(model)}."
end
end

defp sampling_change_warning(key, before, after_translation) do
"#{inspect(key)} was changed from #{inspect(before)} to #{inspect(after_translation)} " <>
"because extended thinking requires it."
end

defp thinking_on?(thinking) when is_map(thinking),
do: not ReqLLM.ModelHelpers.anthropic_thinking_off?(thinking)

defp thinking_on?(_thinking), do: false

defp model_name(%LLMDB.Model{} = model), do: model.provider_model_id || model.id || "This model"
defp model_name(_model), do: "This model"

defp put_reasoning_effort(opts, model, effort, reasoning_budget) do
if ReqLLM.ModelHelpers.adaptive_thinking_required?(model) do
opts
Expand Down Expand Up @@ -1541,14 +1636,23 @@ defmodule ReqLLM.Providers.Anthropic do
|> Keyword.put(:thinking, put_default_adaptive_thinking_display(thinking))
|> remove_adaptive_thinking_sampling_params()

_ ->
opts
thinking ->
normalize_thinking_off(opts, thinking, model)
end
else
opts
end
end

defp normalize_thinking_off(opts, thinking, model) do
with true <- ReqLLM.ModelHelpers.anthropic_thinking_off?(thinking),
type when is_binary(type) <- ReqLLM.ModelHelpers.anthropic_thinking_off_type(model) do
Keyword.put(opts, :thinking, %{type: type})
else
_ -> opts
end
end

defp put_default_adaptive_thinking_display(%{display: _} = thinking), do: thinking
defp put_default_adaptive_thinking_display(%{"display" => _} = thinking), do: thinking

Expand Down Expand Up @@ -1622,13 +1726,18 @@ defmodule ReqLLM.Providers.Anthropic do
defp normalized_effort_values(_values), do: []

defp remove_model_unsupported_parameters(opts, model) do
if model_extra(model, [:temperature]) == false do
if sampling_unsupported?(model) do
Keyword.drop(opts, [:temperature, :top_p, :top_k, :anthropic_top_k])
else
opts
end
end

defp sampling_unsupported?(model) do
model_extra(model, [:temperature]) == false or
ReqLLM.ModelHelpers.adaptive_thinking_required?(model)
end

defp model_capability(%LLMDB.Model{capabilities: capabilities}, path),
do: get_nested(capabilities, path)

Expand Down Expand Up @@ -1670,6 +1779,9 @@ defmodule ReqLLM.Providers.Anthropic do
is_nil(thinking) ->
opts

ReqLLM.ModelHelpers.anthropic_thinking_off?(thinking) ->
opts

operation == :object and match?(%{type: "tool"}, tool_choice) ->
opts
|> Keyword.delete(:thinking)
Expand Down Expand Up @@ -1698,13 +1810,15 @@ defmodule ReqLLM.Providers.Anthropic do
end)
|> Keyword.delete(:temperature)
|> Keyword.delete(:top_k)
|> Keyword.delete(:anthropic_top_k)
end

defp remove_adaptive_thinking_sampling_params(opts) do
opts
|> Keyword.delete(:temperature)
|> Keyword.delete(:top_p)
|> Keyword.delete(:top_k)
|> Keyword.delete(:anthropic_top_k)
end

defp adjust_parameter(opts, key, fun) do
Expand Down
14 changes: 12 additions & 2 deletions lib/req_llm/providers/anthropic/adapter_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ defmodule ReqLLM.Providers.Anthropic.AdapterHelpers do
defp get_opt(_opts, _key), do: nil

@doc """
Add extended thinking configuration to request body if enabled.
Add the thinking configuration to the request body.

Thinking that is turned off (`type: "disabled"` or `type: "between_tools"`)
is always sent, as the bare type, since those types take no other field and
are compatible with forced tool choice.

Extended thinking doesn't work when tool_choice forces a specific tool.
See: https://docs.claude.com/en/docs/build-with-claude/extended-thinking
Expand All @@ -133,8 +137,14 @@ defmodule ReqLLM.Providers.Anthropic.AdapterHelpers do
thinking_config =
get_in(opts, [:provider_options, :additional_model_request_fields, :thinking])

tool_choice = opts[:tool_choice]
if ReqLLM.ModelHelpers.anthropic_thinking_off?(thinking_config) do
Map.put(body, :thinking, %{type: ReqLLM.MapAccess.get(thinking_config, :type)})
else
maybe_add_thinking_on(body, thinking_config, opts[:tool_choice])
end
end

defp maybe_add_thinking_on(body, thinking_config, tool_choice) do
forced_tool_choice? =
case tool_choice do
%{type: "tool", name: _} -> true
Expand Down
Loading
Loading