From dc39201bab7b97a1807d48e169f9a3150eeb40c4 Mon Sep 17 00:00:00 2001 From: Edgars Beigarts Date: Thu, 4 Jun 2026 16:19:10 +0300 Subject: [PATCH] Support Anthropic adaptive thinking Newer Claude models (Opus 4.7+) reject thinking.type "enabled" and require type "adaptive" with output_config.effort instead. Send the adaptive format when an effort is given, keep the budget_tokens format when a budget is given, and merge output_config so structured output and adaptive thinking compose. Fixes https://github.com/crmne/ruby_llm/issues/782 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/_core_features/thinking.md | 4 +- lib/ruby_llm/providers/anthropic/chat.rb | 27 ++++++-- .../ruby_llm/providers/anthropic/chat_spec.rb | 67 +++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/docs/_core_features/thinking.md b/docs/_core_features/thinking.md index 6d66a1592..1e7d92876 100644 --- a/docs/_core_features/thinking.md +++ b/docs/_core_features/thinking.md @@ -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. diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index c0fe9d788..aba3a5b14 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -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 @@ -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) @@ -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 diff --git a/spec/ruby_llm/providers/anthropic/chat_spec.rb b/spec/ruby_llm/providers/anthropic/chat_spec.rb index 5831604ea..46e4aa689 100644 --- a/spec/ruby_llm/providers/anthropic/chat_spec.rb +++ b/spec/ruby_llm/providers/anthropic/chat_spec.rb @@ -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 = {