Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/_core_features/thinking.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ end

## Provider Notes

- Claude uses a thinking budget and can return both text and signature.
- Anthropic requires a thinking budget.
- Claude supports budget or effort depending on the model, and can return both text and signature.
- Anthropic requires a thinking budget or effort, depending on the model.
- Bedrock thinking params are model-dependent; models may accept budget, effort, or provider-specific fields.
- Gemini 2.5 uses a token budget; Gemini 3 uses effort levels.
- OpenAI reasoning models accept `effort` but may not return thinking text or signatures.
Expand Down
27 changes: 22 additions & 5 deletions lib/ruby_llm/providers/anthropic/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def build_base_payload(chat_messages, model, stream, thinking)
}

thinking_payload = build_thinking_payload(thinking)
payload[:thinking] = thinking_payload if thinking_payload
payload.merge!(thinking_payload) if thinking_payload

payload
end
Expand All @@ -68,7 +68,7 @@ def add_optional_fields(payload, system_content:, tools:, tool_prefs:, temperatu
end
payload[:system] = system_content unless system_content.empty?
payload[:temperature] = temperature unless temperature.nil?
payload[:output_config] = build_output_config(schema) if schema
payload[:output_config] = payload.fetch(:output_config, {}).merge(build_output_config(schema)) if schema
end

def build_output_config(schema)
Expand Down Expand Up @@ -235,14 +235,31 @@ def build_thinking_payload(thinking)
return nil unless thinking&.enabled?

budget = resolve_budget(thinking)
raise ArgumentError, 'Anthropic thinking requires a budget' if budget.nil?
if budget
return {
thinking: {
type: 'enabled',
budget_tokens: budget
}
}
end

effort = resolve_effort(thinking)
raise ArgumentError, 'Anthropic thinking requires an effort or a budget' if effort.nil?
return nil if effort == 'none'

{
type: 'enabled',
budget_tokens: budget
thinking: { type: 'adaptive' },
output_config: { effort: effort }
}
end

def resolve_effort(thinking)
effort = thinking.respond_to?(:effort) ? thinking.effort : nil
effort = effort.to_s if effort
effort.nil? || effort.empty? ? nil : effort
end

def resolve_budget(thinking)
budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
budget.is_a?(Integer) ? budget : nil
Expand Down
67 changes: 67 additions & 0 deletions spec/ruby_llm/providers/anthropic/chat_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,73 @@
end
end

describe '.render_payload with thinking' do
let(:model) { instance_double(RubyLLM::Model::Info, id: 'claude-opus-4-7', max_tokens: nil) }
let(:user_message) { RubyLLM::Message.new(role: :user, content: 'Hello') }

def render(thinking:, schema: nil)
described_class.render_payload(
[user_message],
tools: {},
temperature: nil,
model: model,
stream: false,
schema: schema,
thinking: thinking
)
end

it 'sends enabled thinking with budget_tokens when a budget is given' do
payload = render(thinking: RubyLLM::Thinking::Config.new(budget: 2048))

expect(payload[:thinking]).to eq(type: 'enabled', budget_tokens: 2048)
expect(payload).not_to have_key(:output_config)
end

it 'sends adaptive thinking with output_config effort when an effort is given' do
payload = render(thinking: RubyLLM::Thinking::Config.new(effort: :medium))

expect(payload[:thinking]).to eq(type: 'adaptive')
expect(payload[:output_config]).to eq(effort: 'medium')
end

it 'prefers budget over effort when both are given' do
payload = render(thinking: RubyLLM::Thinking::Config.new(effort: :high, budget: 2048))

expect(payload[:thinking]).to eq(type: 'enabled', budget_tokens: 2048)
expect(payload).not_to have_key(:output_config)
end

it 'omits thinking when effort is none' do
payload = render(thinking: RubyLLM::Thinking::Config.new(effort: :none))

expect(payload).not_to have_key(:thinking)
expect(payload).not_to have_key(:output_config)
end

it 'omits thinking when no thinking config is given' do
payload = render(thinking: nil)

expect(payload).not_to have_key(:thinking)
expect(payload).not_to have_key(:output_config)
end

it 'merges effort with schema output_config' do
schema = {
name: 'response',
schema: { type: 'object', properties: { name: { type: 'string' } } }
}

payload = render(thinking: RubyLLM::Thinking::Config.new(effort: :low), schema: schema)

expect(payload[:thinking]).to eq(type: 'adaptive')
expect(payload[:output_config]).to eq(
effort: 'low',
format: { type: 'json_schema', schema: { type: 'object', properties: { name: { type: 'string' } } } }
)
end
end

describe '.parse_completion_response' do
it 'captures cache usage metrics on the message' do
response_body = {
Expand Down
Loading