Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
04f0a8b
fix tool function name return value
sergiobayona Nov 22, 2024
f9953be
use the openai client chat function
sergiobayona Nov 22, 2024
edebf01
refreshed vcr cassette
sergiobayona Nov 22, 2024
4d201ea
fixes
sergiobayona Jan 10, 2025
ceadd29
update easy talk
sergiobayona Mar 14, 2025
d26f225
two basic modes
sergiobayona Mar 14, 2025
691ea52
support for structured and tool response
sergiobayona Mar 14, 2025
1d8f4f3
new patch design
sergiobayona Mar 14, 2025
1fba7a1
named spaced responses
sergiobayona Mar 14, 2025
2d61629
adjust per EasyTalk changes
sergiobayona Mar 14, 2025
d4d7481
improved response handling
sergiobayona Mar 14, 2025
b7796a4
refactored response module
sergiobayona Mar 18, 2025
c61496e
updated specs
sergiobayona Mar 18, 2025
8d3bf20
namedspaced mode setting
sergiobayona Mar 18, 2025
d64aa7d
patch fixes
sergiobayona Jun 12, 2025
59ec585
use easy_talk 2
sergiobayona Jun 12, 2025
e01f295
ignore CLAUDE.md
sergiobayona Jun 12, 2025
ca3ab9f
Add unified Instructor::Mode system for OpenAI and Anthropic
sergiobayona Sep 30, 2025
7d6a5ee
updated gem dependencies versions
sergiobayona Sep 30, 2025
f7788da
new mode mechanism
sergiobayona Oct 2, 2025
af3928b
feature specs name-spaced by provider
sergiobayona Oct 2, 2025
5897167
updated cassettes
sergiobayona Oct 2, 2025
7b09f4c
use sonnet 4
sergiobayona Oct 2, 2025
6250986
anthropic mode specs
sergiobayona Oct 2, 2025
47d78e3
trigger a validation error
sergiobayona Oct 2, 2025
8d9de60
trigger refusal
sergiobayona Oct 2, 2025
6c09306
updated response
sergiobayona Oct 2, 2025
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ Gemfile.lock
# Used by RuboCop. Remote config files pulled in from inherit_from directive.
# .rubocop-https?--*
site/
CLAUDE.md
8 changes: 4 additions & 4 deletions instructor-rb.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ Gem::Specification.new do |spec|

spec.require_paths = ['lib']

spec.add_dependency 'activesupport', '~> 7.0'
spec.add_dependency 'anthropic', '~> 0.2'
spec.add_dependency 'easy_talk', '~> 0.2'
spec.add_dependency 'ruby-openai', '~> 7'
spec.add_dependency 'activesupport', '>= 6.0'
spec.add_dependency 'easy_talk', '~> 2'
spec.add_dependency 'ruby-anthropic', '~> 0.4'
spec.add_dependency 'ruby-openai', '~> 8'
spec.add_development_dependency 'pry-byebug', '~> 3.10'
spec.add_development_dependency 'rake', '~> 13.1'
spec.add_development_dependency 'rspec', '~> 3.0'
Expand Down
40 changes: 29 additions & 11 deletions lib/instructor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,60 @@
require 'easy_talk'
require 'active_support/all'
require_relative 'instructor/version'
require_relative 'instructor/mode'
require_relative 'instructor/openai/patch'
require_relative 'instructor/openai/response'
require_relative 'instructor/openai/mode'
require_relative 'instructor/anthropic/mode'
require_relative 'instructor/anthropic/patch'
require_relative 'instructor/anthropic/response'
require_relative 'instructor/mode'

# Instructor makes it easy to reliably get structured data like JSON from Large Language Models (LLMs)
# like GPT-3.5, GPT-4, GPT-4-Vision
module Instructor
@mode = nil

class Error < ::StandardError; end

# The ValidationError class represents an error that occurs during validation.
class ValidationError < ::StandardError; end

def self.mode
@mode
end

# Patches the OpenAI client to add the following functionality:
# - Retries on exceptions
# - Accepts and validates a response model
# - Accepts a validation_context argument
#
# @param openai_client [OpenAI::Client] The OpenAI client to be patched.
# @param mode [Symbol] The mode to be used. Default is `Instructor::Mode::TOOLS.function`.
# @param mode [Symbol] The mode to be used. Default is `Instructor::Mode::TOOLS_STRICT`.
# @return [OpenAI::Client] The patched OpenAI client.
def self.from_openai(openai_client, mode: Instructor::Mode::TOOLS.function)
@mode = mode
# @example Using tools strict mode (default)
# client = Instructor.from_openai(openai_client)
# @example Using standard tools mode
# client = Instructor.from_openai(openai_client, mode: Instructor::Mode::TOOLS)
# @example Using JSON mode
# client = Instructor.from_openai(openai_client, mode: Instructor::Mode::JSON)
def self.from_openai(openai_client, mode: Instructor::Mode::TOOLS_STRICT)
Instructor::OpenAI.mode = mode
openai_client.prepend(Instructor::OpenAI::Patch)
end

# Patches the Anthropic client to add the following functionality:
# - Retries on exceptions
# - Accepts and validates a response model
# - Accepts a validation_context argument
# - Supports multiple extraction modes
#
# @param anthropic_client [Anthropic::Client] The Anthropic client to be patched.
# @param mode [Symbol] The mode to be used. Default is `Instructor::Mode::ANTHROPIC_TOOLS`.
# @return [Anthropic::Client] The patched Anthropic client.
def self.from_anthropic(anthropic_client)
# @example Using tools mode (default) - forces specific tool use
# client = Instructor.from_anthropic(anthropic_client)
# @example Using JSON mode - prompt-based extraction
# client = Instructor.from_anthropic(anthropic_client, mode: Instructor::Mode::ANTHROPIC_JSON)
# @example Using reasoning tools mode - allows Claude to reason
# client = Instructor.from_anthropic(anthropic_client, mode: Instructor::Mode::ANTHROPIC_REASONING_TOOLS)
# @example Using parallel tools mode - multiple tools
# client = Instructor.from_anthropic(anthropic_client, mode: Instructor::Mode::ANTHROPIC_PARALLEL_TOOLS)
def self.from_anthropic(anthropic_client, mode: Instructor::Mode::ANTHROPIC_TOOLS)
Instructor::Anthropic.mode = mode
anthropic_client.prepend(Instructor::Anthropic::Patch)
end
end
23 changes: 23 additions & 0 deletions lib/instructor/anthropic/mode.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require_relative '../mode'

module Instructor
# Anthropic-specific mode configuration and management
module Anthropic
# Sets the current mode for Anthropic API interactions
#
# @param mode [Symbol] The mode to use (from Instructor::Mode constants)
# @return [Symbol] The mode that was set
def self.mode=(mode)
@mode = mode
end

# Gets the current mode for Anthropic API interactions
#
# @return [Symbol] The current mode, defaults to ANTHROPIC_TOOLS
def self.mode
@mode ||= Instructor::Mode::ANTHROPIC_TOOLS
end
end
end
139 changes: 129 additions & 10 deletions lib/instructor/anthropic/patch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,141 @@ module Anthropic
module Patch
include Instructor::Base::Patch

# Sends a message request to the API and processes the response.
# Sends a chat request to the API and processes the response.
#
# @param parameters [Hash] The parameters for the chat request as expected by the OpenAI client.
# @param parameters [Hash] The parameters for the chat request as expected by the Anthropic client.
# @param response_model [Class] The response model class.
# @param max_retries [Integer] The maximum number of retries. Default is 0.
# @param validation_context [Hash] The validation context for the parameters. Optional.
# @return [Object] The processed response.
def messages(parameters:, response_model: nil, max_retries: 0, validation_context: nil)
with_retries(max_retries, [JSON::ParserError, Instructor::ValidationError, Faraday::ParsingError]) do
model = determine_model(response_model)
return super(parameters:) if response_model.nil?

model = determine_model(response_model)
current_mode = Instructor::Anthropic.mode

set_max_tokens(parameters)

# Mode-specific parameter preparation
if tool_mode?(current_mode)
function = build_function(model)
parameters[:max_tokens] = 1024 unless parameters.key?(:max_tokens)
parameters = prepare_parameters(parameters, validation_context, function)
::Anthropic.configuration.extra_headers = { 'anthropic-beta' => 'tools-2024-04-04' }
response = ::Anthropic::Client.json_post(path: '/messages', parameters:)
process_response(response, model)
parameters = prepare_tool_parameters(parameters, validation_context, function, current_mode)
set_extra_headers
elsif json_mode?(current_mode)
parameters = prepare_json_parameters(parameters, validation_context, model)
else
raise ArgumentError, "Invalid Anthropic mode: #{current_mode}"
end

response = super(parameters:)
process_response(response, model)
end

private

# Checks if the current mode is a tool-based mode
#
# @param mode [Symbol] The mode to check
# @return [Boolean] true if mode uses tools
def tool_mode?(mode)
Instructor::Mode.tool_mode?(mode) && mode.to_s.start_with?('anthropic')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method tool_mode? uses mode.to_s.start_with!("anthropic") to decide if the mode is tool‐based. This string-based check is a bit brittle. Consider comparing against a set of known Anthropic mode symbols for more clarity.

Suggested change
Instructor::Mode.tool_mode?(mode) && mode.to_s.start_with?('anthropic')
Instructor::Mode.tool_mode?(mode) && [Instructor::Mode::ANTHROPIC_TOOLS, Instructor::Mode::ANTHROPIC_REASONING_TOOLS, Instructor::Mode::ANTHROPIC_PARALLEL_TOOLS].include?(mode)

end

# Checks if the current mode is a JSON-based mode
#
# @param mode [Symbol] The mode to check
# @return [Boolean] true if mode uses JSON prompting
def json_mode?(mode)
mode == Instructor::Mode::ANTHROPIC_JSON
end

def set_max_tokens(parameters)
parameters[:max_tokens] = 1024 unless parameters.key?(:max_tokens)
end

def set_extra_headers
::Anthropic.configuration.extra_headers = { 'anthropic-beta' => 'tools-2024-04-04' }
end

def function_name(function)
function[:name]
end

# Prepares parameters for tool-based modes
#
# @param parameters [Hash] Original parameters
# @param validation_context [Hash] Validation context
# @param function [Hash] Function/tool definition
# @param mode [Symbol] Current mode
# @return [Hash] Prepared parameters with tools and tool_choice
def prepare_tool_parameters(parameters, validation_context, function, mode)
parameters = apply_validation_context(parameters, validation_context)
parameters = parameters.merge(tools: [function])

tool_choice = resolve_tool_choice(function_name(function), mode)
parameters.merge!(tool_choice:) if tool_choice

parameters
end

# Prepares parameters for JSON mode (prompt-based)
#
# @param parameters [Hash] Original parameters
# @param validation_context [Hash] Validation context
# @param model [Class] Response model class
# @return [Hash] Prepared parameters with JSON schema in system prompt
def prepare_json_parameters(parameters, validation_context, model)
parameters = apply_validation_context(parameters, validation_context)

# Generate JSON schema prompt
json_schema_message = <<~PROMPT.strip
As a genius expert, your task is to understand the content and provide
the parsed objects in json that match the following json_schema:

#{JSON.pretty_generate(model.json_schema)}

Make sure to return an instance of the JSON, not the schema itself.
PROMPT

# Inject into system messages
system_messages = build_system_messages(parameters[:system], json_schema_message)
parameters.merge(system: system_messages)
end

# Builds system messages array combining existing and schema messages
#
# @param existing_system [String, Array, nil] Existing system messages
# @param schema_message [String] JSON schema instruction message
# @return [Array<Hash>] Array of system message hashes
def build_system_messages(existing_system, schema_message)
messages = []

# Add existing system messages
if existing_system.is_a?(String)
messages << { type: 'text', text: existing_system }
elsif existing_system.is_a?(Array)
messages.concat(existing_system)
end

# Add schema message
messages << { type: 'text', text: schema_message }

messages
end

# Resolves tool_choice based on mode
#
# @param function_name [String] Name of the function/tool
# @param mode [Symbol] Current mode
# @return [Hash, nil] Tool choice configuration or nil
def resolve_tool_choice(function_name, mode)
case mode
when Instructor::Mode::ANTHROPIC_TOOLS
# Force specific tool use
{ type: 'tool', name: function_name }
when Instructor::Mode::ANTHROPIC_REASONING_TOOLS, Instructor::Mode::ANTHROPIC_PARALLEL_TOOLS
# Allow Claude to reason/choose
{ type: 'auto' }
end
end

Expand All @@ -35,7 +154,7 @@ def messages(parameters:, response_model: nil, max_retries: 0, validation_contex
# @param model [Class] The response model class.
# @return [Object] The processed response.
def process_response(response, model)
parsed_response = Response.new(response).parse
parsed_response = Response.create(response).parse
iterable? ? process_multiple_responses(parsed_response, model) : process_single_response(parsed_response, model)
end

Expand Down
Loading
Loading